1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
19 "git.curoverse.com/arvados.git/sdk/go/arvadostest"
20 "git.curoverse.com/arvados.git/sdk/go/auth"
21 check "gopkg.in/check.v1"
24 var _ = check.Suite(&UnitSuite{})
26 type UnitSuite struct{}
28 func (s *UnitSuite) TestCORSPreflight(c *check.C) {
29 h := handler{Config: DefaultConfig()}
30 u, _ := url.Parse("http://keep-web.example/c=" + arvadostest.FooCollection + "/foo")
35 RequestURI: u.RequestURI(),
37 "Origin": {"https://workbench.example"},
38 "Access-Control-Request-Method": {"POST"},
42 // Check preflight for an allowed request
43 resp := httptest.NewRecorder()
44 h.ServeHTTP(resp, req)
45 c.Check(resp.Code, check.Equals, http.StatusOK)
46 c.Check(resp.Body.String(), check.Equals, "")
47 c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, "*")
48 c.Check(resp.Header().Get("Access-Control-Allow-Methods"), check.Equals, "COPY, DELETE, GET, MKCOL, MOVE, OPTIONS, POST, PROPFIND, PUT, RMCOL")
49 c.Check(resp.Header().Get("Access-Control-Allow-Headers"), check.Equals, "Authorization, Content-Type, Range")
51 // Check preflight for a disallowed request
52 resp = httptest.NewRecorder()
53 req.Header.Set("Access-Control-Request-Method", "MAKE-COFFEE")
54 h.ServeHTTP(resp, req)
55 c.Check(resp.Body.String(), check.Equals, "")
56 c.Check(resp.Code, check.Equals, http.StatusMethodNotAllowed)
59 func (s *UnitSuite) TestInvalidUUID(c *check.C) {
60 bogusID := strings.Replace(arvadostest.FooPdh, "+", "-", 1) + "-"
61 token := arvadostest.ActiveToken
62 for _, trial := range []string{
63 "http://keep-web/c=" + bogusID + "/foo",
64 "http://keep-web/c=" + bogusID + "/t=" + token + "/foo",
65 "http://keep-web/collections/download/" + bogusID + "/" + token + "/foo",
66 "http://keep-web/collections/" + bogusID + "/foo",
67 "http://" + bogusID + ".keep-web/" + bogusID + "/foo",
68 "http://" + bogusID + ".keep-web/t=" + token + "/" + bogusID + "/foo",
71 u, err := url.Parse(trial)
72 c.Assert(err, check.IsNil)
77 RequestURI: u.RequestURI(),
79 resp := httptest.NewRecorder()
80 cfg := DefaultConfig()
81 cfg.AnonymousTokens = []string{arvadostest.AnonymousToken}
82 h := handler{Config: cfg}
83 h.ServeHTTP(resp, req)
84 c.Check(resp.Code, check.Equals, http.StatusNotFound)
88 func mustParseURL(s string) *url.URL {
89 r, err := url.Parse(s)
91 panic("parse URL: " + s)
96 func (s *IntegrationSuite) TestVhost404(c *check.C) {
97 for _, testURL := range []string{
98 arvadostest.NonexistentCollection + ".example.com/theperthcountyconspiracy",
99 arvadostest.NonexistentCollection + ".example.com/t=" + arvadostest.ActiveToken + "/theperthcountyconspiracy",
101 resp := httptest.NewRecorder()
102 u := mustParseURL(testURL)
103 req := &http.Request{
106 RequestURI: u.RequestURI(),
108 s.testServer.Handler.ServeHTTP(resp, req)
109 c.Check(resp.Code, check.Equals, http.StatusNotFound)
110 c.Check(resp.Body.String(), check.Equals, "")
114 // An authorizer modifies an HTTP request to make use of the given
115 // token -- by adding it to a header, cookie, query param, or whatever
116 // -- and returns the HTTP status code we should expect from keep-web if
117 // the token is invalid.
118 type authorizer func(*http.Request, string) int
120 func (s *IntegrationSuite) TestVhostViaAuthzHeader(c *check.C) {
121 s.doVhostRequests(c, authzViaAuthzHeader)
123 func authzViaAuthzHeader(r *http.Request, tok string) int {
124 r.Header.Add("Authorization", "OAuth2 "+tok)
125 return http.StatusUnauthorized
128 func (s *IntegrationSuite) TestVhostViaCookieValue(c *check.C) {
129 s.doVhostRequests(c, authzViaCookieValue)
131 func authzViaCookieValue(r *http.Request, tok string) int {
132 r.AddCookie(&http.Cookie{
133 Name: "arvados_api_token",
134 Value: auth.EncodeTokenCookie([]byte(tok)),
136 return http.StatusUnauthorized
139 func (s *IntegrationSuite) TestVhostViaPath(c *check.C) {
140 s.doVhostRequests(c, authzViaPath)
142 func authzViaPath(r *http.Request, tok string) int {
143 r.URL.Path = "/t=" + tok + r.URL.Path
144 return http.StatusNotFound
147 func (s *IntegrationSuite) TestVhostViaQueryString(c *check.C) {
148 s.doVhostRequests(c, authzViaQueryString)
150 func authzViaQueryString(r *http.Request, tok string) int {
151 r.URL.RawQuery = "api_token=" + tok
152 return http.StatusUnauthorized
155 func (s *IntegrationSuite) TestVhostViaPOST(c *check.C) {
156 s.doVhostRequests(c, authzViaPOST)
158 func authzViaPOST(r *http.Request, tok string) int {
160 r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
161 r.Body = ioutil.NopCloser(strings.NewReader(
162 url.Values{"api_token": {tok}}.Encode()))
163 return http.StatusUnauthorized
166 func (s *IntegrationSuite) TestVhostViaXHRPOST(c *check.C) {
167 s.doVhostRequests(c, authzViaPOST)
169 func authzViaXHRPOST(r *http.Request, tok string) int {
171 r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
172 r.Header.Add("Origin", "https://origin.example")
173 r.Body = ioutil.NopCloser(strings.NewReader(
176 "disposition": {"attachment"},
178 return http.StatusUnauthorized
181 // Try some combinations of {url, token} using the given authorization
182 // mechanism, and verify the result is correct.
183 func (s *IntegrationSuite) doVhostRequests(c *check.C, authz authorizer) {
184 for _, hostPath := range []string{
185 arvadostest.FooCollection + ".example.com/foo",
186 arvadostest.FooCollection + "--collections.example.com/foo",
187 arvadostest.FooCollection + "--collections.example.com/_/foo",
188 arvadostest.FooPdh + ".example.com/foo",
189 strings.Replace(arvadostest.FooPdh, "+", "-", -1) + "--collections.example.com/foo",
190 arvadostest.FooBarDirCollection + ".example.com/dir1/foo",
192 c.Log("doRequests: ", hostPath)
193 s.doVhostRequestsWithHostPath(c, authz, hostPath)
197 func (s *IntegrationSuite) doVhostRequestsWithHostPath(c *check.C, authz authorizer, hostPath string) {
198 for _, tok := range []string{
199 arvadostest.ActiveToken,
200 arvadostest.ActiveToken[:15],
201 arvadostest.SpectatorToken,
205 u := mustParseURL("http://" + hostPath)
206 req := &http.Request{
210 RequestURI: u.RequestURI(),
211 Header: http.Header{},
213 failCode := authz(req, tok)
214 req, resp := s.doReq(req)
215 code, body := resp.Code, resp.Body.String()
217 // If the initial request had a (non-empty) token
218 // showing in the query string, we should have been
219 // redirected in order to hide it in a cookie.
220 c.Check(req.URL.String(), check.Not(check.Matches), `.*api_token=.+`)
222 if tok == arvadostest.ActiveToken {
223 c.Check(code, check.Equals, http.StatusOK)
224 c.Check(body, check.Equals, "foo")
227 c.Check(code >= 400, check.Equals, true)
228 c.Check(code < 500, check.Equals, true)
229 if tok == arvadostest.SpectatorToken {
230 // Valid token never offers to retry
231 // with different credentials.
232 c.Check(code, check.Equals, http.StatusNotFound)
234 // Invalid token can ask to retry
235 // depending on the authz method.
236 c.Check(code, check.Equals, failCode)
238 c.Check(body, check.Equals, "")
243 func (s *IntegrationSuite) doReq(req *http.Request) (*http.Request, *httptest.ResponseRecorder) {
244 resp := httptest.NewRecorder()
245 s.testServer.Handler.ServeHTTP(resp, req)
246 if resp.Code != http.StatusSeeOther {
249 cookies := (&http.Response{Header: resp.Header()}).Cookies()
250 u, _ := req.URL.Parse(resp.Header().Get("Location"))
255 RequestURI: u.RequestURI(),
256 Header: http.Header{},
258 for _, c := range cookies {
264 func (s *IntegrationSuite) TestVhostRedirectQueryTokenToCookie(c *check.C) {
265 s.testVhostRedirectTokenToCookie(c, "GET",
266 arvadostest.FooCollection+".example.com/foo",
267 "?api_token="+arvadostest.ActiveToken,
275 func (s *IntegrationSuite) TestSingleOriginSecretLink(c *check.C) {
276 s.testVhostRedirectTokenToCookie(c, "GET",
277 "example.com/c="+arvadostest.FooCollection+"/t="+arvadostest.ActiveToken+"/foo",
286 // Bad token in URL is 404 Not Found because it doesn't make sense to
287 // retry the same URL with different authorization.
288 func (s *IntegrationSuite) TestSingleOriginSecretLinkBadToken(c *check.C) {
289 s.testVhostRedirectTokenToCookie(c, "GET",
290 "example.com/c="+arvadostest.FooCollection+"/t=bogus/foo",
299 // Bad token in a cookie (even if it got there via our own
300 // query-string-to-cookie redirect) is, in principle, retryable at the
301 // same URL so it's 401 Unauthorized.
302 func (s *IntegrationSuite) TestVhostRedirectQueryTokenToBogusCookie(c *check.C) {
303 s.testVhostRedirectTokenToCookie(c, "GET",
304 arvadostest.FooCollection+".example.com/foo",
305 "?api_token=thisisabogustoken",
308 http.StatusUnauthorized,
313 func (s *IntegrationSuite) TestVhostRedirectQueryTokenSingleOriginError(c *check.C) {
314 s.testVhostRedirectTokenToCookie(c, "GET",
315 "example.com/c="+arvadostest.FooCollection+"/foo",
316 "?api_token="+arvadostest.ActiveToken,
319 http.StatusBadRequest,
324 // If client requests an attachment by putting ?disposition=attachment
325 // in the query string, and gets redirected, the redirect target
326 // should respond with an attachment.
327 func (s *IntegrationSuite) TestVhostRedirectQueryTokenRequestAttachment(c *check.C) {
328 resp := s.testVhostRedirectTokenToCookie(c, "GET",
329 arvadostest.FooCollection+".example.com/foo",
330 "?disposition=attachment&api_token="+arvadostest.ActiveToken,
336 c.Check(resp.Header().Get("Content-Disposition"), check.Matches, "attachment(;.*)?")
339 func (s *IntegrationSuite) TestVhostRedirectQueryTokenSiteFS(c *check.C) {
340 s.testServer.Config.AttachmentOnlyHost = "download.example.com"
341 resp := s.testVhostRedirectTokenToCookie(c, "GET",
342 "download.example.com/by_id/"+arvadostest.FooCollection+"/foo",
343 "?api_token="+arvadostest.ActiveToken,
349 c.Check(resp.Header().Get("Content-Disposition"), check.Matches, "attachment(;.*)?")
352 func (s *IntegrationSuite) TestVhostRedirectQueryTokenTrustAllContent(c *check.C) {
353 s.testServer.Config.TrustAllContent = true
354 s.testVhostRedirectTokenToCookie(c, "GET",
355 "example.com/c="+arvadostest.FooCollection+"/foo",
356 "?api_token="+arvadostest.ActiveToken,
364 func (s *IntegrationSuite) TestVhostRedirectQueryTokenAttachmentOnlyHost(c *check.C) {
365 s.testServer.Config.AttachmentOnlyHost = "example.com:1234"
367 s.testVhostRedirectTokenToCookie(c, "GET",
368 "example.com/c="+arvadostest.FooCollection+"/foo",
369 "?api_token="+arvadostest.ActiveToken,
372 http.StatusBadRequest,
376 resp := s.testVhostRedirectTokenToCookie(c, "GET",
377 "example.com:1234/c="+arvadostest.FooCollection+"/foo",
378 "?api_token="+arvadostest.ActiveToken,
384 c.Check(resp.Header().Get("Content-Disposition"), check.Equals, "attachment")
387 func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie(c *check.C) {
388 s.testVhostRedirectTokenToCookie(c, "POST",
389 arvadostest.FooCollection+".example.com/foo",
391 "application/x-www-form-urlencoded",
392 url.Values{"api_token": {arvadostest.ActiveToken}}.Encode(),
398 func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie404(c *check.C) {
399 s.testVhostRedirectTokenToCookie(c, "POST",
400 arvadostest.FooCollection+".example.com/foo",
402 "application/x-www-form-urlencoded",
403 url.Values{"api_token": {arvadostest.SpectatorToken}}.Encode(),
409 func (s *IntegrationSuite) TestAnonymousTokenOK(c *check.C) {
410 s.testServer.Config.AnonymousTokens = []string{arvadostest.AnonymousToken}
411 s.testVhostRedirectTokenToCookie(c, "GET",
412 "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt",
421 func (s *IntegrationSuite) TestAnonymousTokenError(c *check.C) {
422 s.testServer.Config.AnonymousTokens = []string{"anonymousTokenConfiguredButInvalid"}
423 s.testVhostRedirectTokenToCookie(c, "GET",
424 "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt",
433 // XHRs can't follow redirect-with-cookie so they rely on method=POST
434 // and disposition=attachment (telling us it's acceptable to respond
435 // with content instead of a redirect) and an Origin header that gets
436 // added automatically by the browser (telling us it's desirable to do
438 func (s *IntegrationSuite) TestXHRNoRedirect(c *check.C) {
439 u, _ := url.Parse("http://example.com/c=" + arvadostest.FooCollection + "/foo")
440 req := &http.Request{
444 RequestURI: u.RequestURI(),
446 "Origin": {"https://origin.example"},
447 "Content-Type": {"application/x-www-form-urlencoded"},
449 Body: ioutil.NopCloser(strings.NewReader(url.Values{
450 "api_token": {arvadostest.ActiveToken},
451 "disposition": {"attachment"},
454 resp := httptest.NewRecorder()
455 s.testServer.Handler.ServeHTTP(resp, req)
456 c.Check(resp.Code, check.Equals, http.StatusOK)
457 c.Check(resp.Body.String(), check.Equals, "foo")
458 c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, "*")
461 func (s *IntegrationSuite) testVhostRedirectTokenToCookie(c *check.C, method, hostPath, queryString, contentType, reqBody string, expectStatus int, expectRespBody string) *httptest.ResponseRecorder {
462 u, _ := url.Parse(`http://` + hostPath + queryString)
463 req := &http.Request{
467 RequestURI: u.RequestURI(),
468 Header: http.Header{"Content-Type": {contentType}},
469 Body: ioutil.NopCloser(strings.NewReader(reqBody)),
472 resp := httptest.NewRecorder()
474 c.Check(resp.Code, check.Equals, expectStatus)
475 c.Check(resp.Body.String(), check.Equals, expectRespBody)
478 s.testServer.Handler.ServeHTTP(resp, req)
479 if resp.Code != http.StatusSeeOther {
482 c.Check(resp.Body.String(), check.Matches, `.*href="//`+regexp.QuoteMeta(html.EscapeString(hostPath))+`(\?[^"]*)?".*`)
483 cookies := (&http.Response{Header: resp.Header()}).Cookies()
485 u, _ = u.Parse(resp.Header().Get("Location"))
490 RequestURI: u.RequestURI(),
491 Header: http.Header{},
493 for _, c := range cookies {
497 resp = httptest.NewRecorder()
498 s.testServer.Handler.ServeHTTP(resp, req)
499 c.Check(resp.Header().Get("Location"), check.Equals, "")
503 func (s *IntegrationSuite) TestDirectoryListing(c *check.C) {
504 s.testServer.Config.AttachmentOnlyHost = "download.example.com"
505 authHeader := http.Header{
506 "Authorization": {"OAuth2 " + arvadostest.ActiveToken},
508 for _, trial := range []struct {
516 uri: strings.Replace(arvadostest.FooAndBarFilesInDirPDH, "+", "-", -1) + ".example.com/",
518 expect: []string{"dir1/foo", "dir1/bar"},
522 uri: strings.Replace(arvadostest.FooAndBarFilesInDirPDH, "+", "-", -1) + ".example.com/dir1/",
524 expect: []string{"foo", "bar"},
528 uri: "download.example.com/collections/" + arvadostest.FooAndBarFilesInDirUUID + "/",
530 expect: []string{"dir1/foo", "dir1/bar"},
534 uri: "download.example.com/users/active/foo_file_in_dir/",
536 expect: []string{"dir1/"},
540 uri: "download.example.com/users/active/foo_file_in_dir/dir1/",
542 expect: []string{"bar"},
546 uri: "download.example.com/",
548 expect: []string{"users/"},
552 uri: "download.example.com/users",
555 expect: []string{"active/"},
559 uri: "download.example.com/users/",
561 expect: []string{"active/"},
565 uri: "download.example.com/users/active",
567 redirect: "/users/active/",
568 expect: []string{"foo_file_in_dir/"},
572 uri: "download.example.com/users/active/",
574 expect: []string{"foo_file_in_dir/"},
578 uri: "collections.example.com/collections/download/" + arvadostest.FooAndBarFilesInDirUUID + "/" + arvadostest.ActiveToken + "/",
580 expect: []string{"dir1/foo", "dir1/bar"},
584 uri: "collections.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/t=" + arvadostest.ActiveToken + "/",
586 expect: []string{"dir1/foo", "dir1/bar"},
590 uri: "collections.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/t=" + arvadostest.ActiveToken,
592 expect: []string{"dir1/foo", "dir1/bar"},
596 uri: "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID,
598 expect: []string{"dir1/foo", "dir1/bar"},
602 uri: "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/dir1",
604 redirect: "/c=" + arvadostest.FooAndBarFilesInDirUUID + "/dir1/",
605 expect: []string{"foo", "bar"},
609 uri: "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/_/dir1/",
611 expect: []string{"foo", "bar"},
615 uri: arvadostest.FooAndBarFilesInDirUUID + ".example.com/dir1?api_token=" + arvadostest.ActiveToken,
618 expect: []string{"foo", "bar"},
622 uri: "collections.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/theperthcountyconspiracydoesnotexist/",
627 c.Logf("HTML: %q => %q", trial.uri, trial.expect)
628 resp := httptest.NewRecorder()
629 u := mustParseURL("//" + trial.uri)
630 req := &http.Request{
634 RequestURI: u.RequestURI(),
635 Header: trial.header,
637 s.testServer.Handler.ServeHTTP(resp, req)
638 var cookies []*http.Cookie
639 for resp.Code == http.StatusSeeOther {
640 u, _ := req.URL.Parse(resp.Header().Get("Location"))
645 RequestURI: u.RequestURI(),
646 Header: trial.header,
648 cookies = append(cookies, (&http.Response{Header: resp.Header()}).Cookies()...)
649 for _, c := range cookies {
652 resp = httptest.NewRecorder()
653 s.testServer.Handler.ServeHTTP(resp, req)
655 if trial.redirect != "" {
656 c.Check(req.URL.Path, check.Equals, trial.redirect)
658 if trial.expect == nil {
659 c.Check(resp.Code, check.Equals, http.StatusNotFound)
661 c.Check(resp.Code, check.Equals, http.StatusOK)
662 for _, e := range trial.expect {
663 c.Check(resp.Body.String(), check.Matches, `(?ms).*href="`+e+`".*`)
665 c.Check(resp.Body.String(), check.Matches, `(?ms).*--cut-dirs=`+fmt.Sprintf("%d", trial.cutDirs)+` .*`)
668 c.Logf("WebDAV: %q => %q", trial.uri, trial.expect)
673 RequestURI: u.RequestURI(),
674 Header: trial.header,
675 Body: ioutil.NopCloser(&bytes.Buffer{}),
677 resp = httptest.NewRecorder()
678 s.testServer.Handler.ServeHTTP(resp, req)
679 if trial.expect == nil {
680 c.Check(resp.Code, check.Equals, http.StatusNotFound)
682 c.Check(resp.Code, check.Equals, http.StatusOK)
689 RequestURI: u.RequestURI(),
690 Header: trial.header,
691 Body: ioutil.NopCloser(&bytes.Buffer{}),
693 resp = httptest.NewRecorder()
694 s.testServer.Handler.ServeHTTP(resp, req)
695 if trial.expect == nil {
696 c.Check(resp.Code, check.Equals, http.StatusNotFound)
698 c.Check(resp.Code, check.Equals, http.StatusMultiStatus)
699 for _, e := range trial.expect {
700 c.Check(resp.Body.String(), check.Matches, `(?ms).*<D:href>`+filepath.Join(u.Path, e)+`</D:href>.*`)
706 func (s *IntegrationSuite) TestHealthCheckPing(c *check.C) {
707 s.testServer.Config.ManagementToken = arvadostest.ManagementToken
708 authHeader := http.Header{
709 "Authorization": {"Bearer " + arvadostest.ManagementToken},
712 resp := httptest.NewRecorder()
713 u := mustParseURL("http://download.example.com/_health/ping")
714 req := &http.Request{
718 RequestURI: u.RequestURI(),
721 s.testServer.Handler.ServeHTTP(resp, req)
723 c.Check(resp.Code, check.Equals, http.StatusOK)
724 c.Check(resp.Body.String(), check.Matches, `{"health":"OK"}\n`)