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(strings.Split(resp.Header().Get("Content-Disposition"), ";")[0], check.Equals, "attachment")
339 func (s *IntegrationSuite) TestVhostRedirectQueryTokenTrustAllContent(c *check.C) {
340 s.testServer.Config.TrustAllContent = true
341 s.testVhostRedirectTokenToCookie(c, "GET",
342 "example.com/c="+arvadostest.FooCollection+"/foo",
343 "?api_token="+arvadostest.ActiveToken,
351 func (s *IntegrationSuite) TestVhostRedirectQueryTokenAttachmentOnlyHost(c *check.C) {
352 s.testServer.Config.AttachmentOnlyHost = "example.com:1234"
354 s.testVhostRedirectTokenToCookie(c, "GET",
355 "example.com/c="+arvadostest.FooCollection+"/foo",
356 "?api_token="+arvadostest.ActiveToken,
359 http.StatusBadRequest,
363 resp := s.testVhostRedirectTokenToCookie(c, "GET",
364 "example.com:1234/c="+arvadostest.FooCollection+"/foo",
365 "?api_token="+arvadostest.ActiveToken,
371 c.Check(resp.Header().Get("Content-Disposition"), check.Equals, "attachment")
374 func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie(c *check.C) {
375 s.testVhostRedirectTokenToCookie(c, "POST",
376 arvadostest.FooCollection+".example.com/foo",
378 "application/x-www-form-urlencoded",
379 url.Values{"api_token": {arvadostest.ActiveToken}}.Encode(),
385 func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie404(c *check.C) {
386 s.testVhostRedirectTokenToCookie(c, "POST",
387 arvadostest.FooCollection+".example.com/foo",
389 "application/x-www-form-urlencoded",
390 url.Values{"api_token": {arvadostest.SpectatorToken}}.Encode(),
396 func (s *IntegrationSuite) TestAnonymousTokenOK(c *check.C) {
397 s.testServer.Config.AnonymousTokens = []string{arvadostest.AnonymousToken}
398 s.testVhostRedirectTokenToCookie(c, "GET",
399 "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt",
408 func (s *IntegrationSuite) TestAnonymousTokenError(c *check.C) {
409 s.testServer.Config.AnonymousTokens = []string{"anonymousTokenConfiguredButInvalid"}
410 s.testVhostRedirectTokenToCookie(c, "GET",
411 "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt",
420 // XHRs can't follow redirect-with-cookie so they rely on method=POST
421 // and disposition=attachment (telling us it's acceptable to respond
422 // with content instead of a redirect) and an Origin header that gets
423 // added automatically by the browser (telling us it's desirable to do
425 func (s *IntegrationSuite) TestXHRNoRedirect(c *check.C) {
426 u, _ := url.Parse("http://example.com/c=" + arvadostest.FooCollection + "/foo")
427 req := &http.Request{
431 RequestURI: u.RequestURI(),
433 "Origin": {"https://origin.example"},
434 "Content-Type": {"application/x-www-form-urlencoded"},
436 Body: ioutil.NopCloser(strings.NewReader(url.Values{
437 "api_token": {arvadostest.ActiveToken},
438 "disposition": {"attachment"},
441 resp := httptest.NewRecorder()
442 s.testServer.Handler.ServeHTTP(resp, req)
443 c.Check(resp.Code, check.Equals, http.StatusOK)
444 c.Check(resp.Body.String(), check.Equals, "foo")
445 c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, "*")
448 func (s *IntegrationSuite) testVhostRedirectTokenToCookie(c *check.C, method, hostPath, queryString, contentType, reqBody string, expectStatus int, expectRespBody string) *httptest.ResponseRecorder {
449 u, _ := url.Parse(`http://` + hostPath + queryString)
450 req := &http.Request{
454 RequestURI: u.RequestURI(),
455 Header: http.Header{"Content-Type": {contentType}},
456 Body: ioutil.NopCloser(strings.NewReader(reqBody)),
459 resp := httptest.NewRecorder()
461 c.Check(resp.Code, check.Equals, expectStatus)
462 c.Check(resp.Body.String(), check.Equals, expectRespBody)
465 s.testServer.Handler.ServeHTTP(resp, req)
466 if resp.Code != http.StatusSeeOther {
469 c.Check(resp.Body.String(), check.Matches, `.*href="//`+regexp.QuoteMeta(html.EscapeString(hostPath))+`(\?[^"]*)?".*`)
470 cookies := (&http.Response{Header: resp.Header()}).Cookies()
472 u, _ = u.Parse(resp.Header().Get("Location"))
477 RequestURI: u.RequestURI(),
478 Header: http.Header{},
480 for _, c := range cookies {
484 resp = httptest.NewRecorder()
485 s.testServer.Handler.ServeHTTP(resp, req)
486 c.Check(resp.Header().Get("Location"), check.Equals, "")
490 func (s *IntegrationSuite) TestDirectoryListing(c *check.C) {
491 s.testServer.Config.AttachmentOnlyHost = "download.example.com"
492 authHeader := http.Header{
493 "Authorization": {"OAuth2 " + arvadostest.ActiveToken},
495 for _, trial := range []struct {
502 uri: strings.Replace(arvadostest.FooAndBarFilesInDirPDH, "+", "-", -1) + ".example.com/",
504 expect: []string{"dir1/foo", "dir1/bar"},
508 uri: strings.Replace(arvadostest.FooAndBarFilesInDirPDH, "+", "-", -1) + ".example.com/dir1/",
510 expect: []string{"foo", "bar"},
514 uri: "download.example.com/collections/" + arvadostest.FooAndBarFilesInDirUUID + "/",
516 expect: []string{"dir1/foo", "dir1/bar"},
520 uri: "collections.example.com/collections/download/" + arvadostest.FooAndBarFilesInDirUUID + "/" + arvadostest.ActiveToken + "/",
522 expect: []string{"dir1/foo", "dir1/bar"},
526 uri: "collections.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/t=" + arvadostest.ActiveToken + "/",
528 expect: []string{"dir1/foo", "dir1/bar"},
532 uri: "collections.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/t=" + arvadostest.ActiveToken,
534 expect: []string{"dir1/foo", "dir1/bar"},
538 uri: "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID,
540 expect: []string{"dir1/foo", "dir1/bar"},
544 uri: "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/dir1/",
546 expect: []string{"foo", "bar"},
550 uri: "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/_/dir1/",
552 expect: []string{"foo", "bar"},
556 uri: arvadostest.FooAndBarFilesInDirUUID + ".example.com/dir1?api_token=" + arvadostest.ActiveToken,
558 expect: []string{"foo", "bar"},
562 uri: "collections.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/theperthcountyconspiracydoesnotexist/",
567 c.Logf("HTML: %q => %q", trial.uri, trial.expect)
568 resp := httptest.NewRecorder()
569 u := mustParseURL("//" + trial.uri)
570 req := &http.Request{
574 RequestURI: u.RequestURI(),
575 Header: trial.header,
577 s.testServer.Handler.ServeHTTP(resp, req)
578 var cookies []*http.Cookie
579 for resp.Code == http.StatusSeeOther {
580 u, _ := req.URL.Parse(resp.Header().Get("Location"))
585 RequestURI: u.RequestURI(),
586 Header: trial.header,
588 cookies = append(cookies, (&http.Response{Header: resp.Header()}).Cookies()...)
589 for _, c := range cookies {
592 resp = httptest.NewRecorder()
593 s.testServer.Handler.ServeHTTP(resp, req)
595 if trial.expect == nil {
596 c.Check(resp.Code, check.Equals, http.StatusNotFound)
598 c.Check(resp.Code, check.Equals, http.StatusOK)
599 for _, e := range trial.expect {
600 c.Check(resp.Body.String(), check.Matches, `(?ms).*href="`+e+`".*`)
602 c.Check(resp.Body.String(), check.Matches, `(?ms).*--cut-dirs=`+fmt.Sprintf("%d", trial.cutDirs)+` .*`)
605 c.Logf("WebDAV: %q => %q", trial.uri, trial.expect)
610 RequestURI: u.RequestURI(),
611 Header: trial.header,
612 Body: ioutil.NopCloser(&bytes.Buffer{}),
614 resp = httptest.NewRecorder()
615 s.testServer.Handler.ServeHTTP(resp, req)
616 if trial.expect == nil {
617 c.Check(resp.Code, check.Equals, http.StatusNotFound)
619 c.Check(resp.Code, check.Equals, http.StatusOK)
626 RequestURI: u.RequestURI(),
627 Header: trial.header,
628 Body: ioutil.NopCloser(&bytes.Buffer{}),
630 resp = httptest.NewRecorder()
631 s.testServer.Handler.ServeHTTP(resp, req)
632 if trial.expect == nil {
633 c.Check(resp.Code, check.Equals, http.StatusNotFound)
635 c.Check(resp.Code, check.Equals, http.StatusMultiStatus)
636 for _, e := range trial.expect {
637 c.Check(resp.Body.String(), check.Matches, `(?ms).*<D:href>`+filepath.Join(u.Path, e)+`</D:href>.*`)
643 func (s *IntegrationSuite) TestHealthCheckPing(c *check.C) {
644 s.testServer.Config.ManagementToken = arvadostest.ManagementToken
645 authHeader := http.Header{
646 "Authorization": {"Bearer " + arvadostest.ManagementToken},
649 resp := httptest.NewRecorder()
650 u := mustParseURL("http://download.example.com/_health/ping")
651 req := &http.Request{
655 RequestURI: u.RequestURI(),
658 s.testServer.Handler.ServeHTTP(resp, req)
660 c.Check(resp.Code, check.Equals, http.StatusOK)
661 c.Check(resp.Body.String(), check.Matches, `{"health":"OK"}\n`)