12483: Merge branch 'master' into 12483-writable-fs
[arvados.git] / services / keep-web / handler_test.go
index d04c5c2d100feff32d9bced628f54bdfd8e51e0a..21e47c8dc7c3e0a64f8d320e24b5cd9041fe7117 100644 (file)
@@ -1,11 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
 package main
 
 import (
+       "bytes"
+       "fmt"
        "html"
        "io/ioutil"
        "net/http"
        "net/http/httptest"
        "net/url"
+       "path/filepath"
        "regexp"
        "strings"
 
@@ -18,6 +25,66 @@ var _ = check.Suite(&UnitSuite{})
 
 type UnitSuite struct{}
 
+func (s *UnitSuite) TestCORSPreflight(c *check.C) {
+       h := handler{Config: DefaultConfig()}
+       u, _ := url.Parse("http://keep-web.example/c=" + arvadostest.FooCollection + "/foo")
+       req := &http.Request{
+               Method:     "OPTIONS",
+               Host:       u.Host,
+               URL:        u,
+               RequestURI: u.RequestURI(),
+               Header: http.Header{
+                       "Origin":                        {"https://workbench.example"},
+                       "Access-Control-Request-Method": {"POST"},
+               },
+       }
+
+       // Check preflight for an allowed request
+       resp := httptest.NewRecorder()
+       h.ServeHTTP(resp, req)
+       c.Check(resp.Code, check.Equals, http.StatusOK)
+       c.Check(resp.Body.String(), check.Equals, "")
+       c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, "*")
+       c.Check(resp.Header().Get("Access-Control-Allow-Methods"), check.Equals, "COPY, DELETE, GET, MKCOL, MOVE, OPTIONS, POST, PROPFIND, PUT, RMCOL")
+       c.Check(resp.Header().Get("Access-Control-Allow-Headers"), check.Equals, "Authorization, Content-Type, Range")
+
+       // Check preflight for a disallowed request
+       resp = httptest.NewRecorder()
+       req.Header.Set("Access-Control-Request-Method", "MAKE-COFFEE")
+       h.ServeHTTP(resp, req)
+       c.Check(resp.Body.String(), check.Equals, "")
+       c.Check(resp.Code, check.Equals, http.StatusMethodNotAllowed)
+}
+
+func (s *UnitSuite) TestInvalidUUID(c *check.C) {
+       bogusID := strings.Replace(arvadostest.FooPdh, "+", "-", 1) + "-"
+       token := arvadostest.ActiveToken
+       for _, trial := range []string{
+               "http://keep-web/c=" + bogusID + "/foo",
+               "http://keep-web/c=" + bogusID + "/t=" + token + "/foo",
+               "http://keep-web/collections/download/" + bogusID + "/" + token + "/foo",
+               "http://keep-web/collections/" + bogusID + "/foo",
+               "http://" + bogusID + ".keep-web/" + bogusID + "/foo",
+               "http://" + bogusID + ".keep-web/t=" + token + "/" + bogusID + "/foo",
+       } {
+               c.Log(trial)
+               u, err := url.Parse(trial)
+               c.Assert(err, check.IsNil)
+               req := &http.Request{
+                       Method:     "GET",
+                       Host:       u.Host,
+                       URL:        u,
+                       RequestURI: u.RequestURI(),
+               }
+               resp := httptest.NewRecorder()
+               cfg := DefaultConfig()
+               cfg.AnonymousTokens = []string{arvadostest.AnonymousToken}
+               h := handler{Config: cfg}
+               h.ServeHTTP(resp, req)
+               c.Check(resp.Code, check.Equals, http.StatusNotFound)
+       }
+}
+
 func mustParseURL(s string) *url.URL {
        r, err := url.Parse(s)
        if err != nil {
@@ -38,7 +105,7 @@ func (s *IntegrationSuite) TestVhost404(c *check.C) {
                        URL:        u,
                        RequestURI: u.RequestURI(),
                }
-               (&handler{}).ServeHTTP(resp, req)
+               s.testServer.Handler.ServeHTTP(resp, req)
                c.Check(resp.Code, check.Equals, http.StatusNotFound)
                c.Check(resp.Body.String(), check.Equals, "")
        }
@@ -51,7 +118,7 @@ func (s *IntegrationSuite) TestVhost404(c *check.C) {
 type authorizer func(*http.Request, string) int
 
 func (s *IntegrationSuite) TestVhostViaAuthzHeader(c *check.C) {
-       doVhostRequests(c, authzViaAuthzHeader)
+       s.doVhostRequests(c, authzViaAuthzHeader)
 }
 func authzViaAuthzHeader(r *http.Request, tok string) int {
        r.Header.Add("Authorization", "OAuth2 "+tok)
@@ -59,7 +126,7 @@ func authzViaAuthzHeader(r *http.Request, tok string) int {
 }
 
 func (s *IntegrationSuite) TestVhostViaCookieValue(c *check.C) {
-       doVhostRequests(c, authzViaCookieValue)
+       s.doVhostRequests(c, authzViaCookieValue)
 }
 func authzViaCookieValue(r *http.Request, tok string) int {
        r.AddCookie(&http.Cookie{
@@ -70,7 +137,7 @@ func authzViaCookieValue(r *http.Request, tok string) int {
 }
 
 func (s *IntegrationSuite) TestVhostViaPath(c *check.C) {
-       doVhostRequests(c, authzViaPath)
+       s.doVhostRequests(c, authzViaPath)
 }
 func authzViaPath(r *http.Request, tok string) int {
        r.URL.Path = "/t=" + tok + r.URL.Path
@@ -78,7 +145,7 @@ func authzViaPath(r *http.Request, tok string) int {
 }
 
 func (s *IntegrationSuite) TestVhostViaQueryString(c *check.C) {
-       doVhostRequests(c, authzViaQueryString)
+       s.doVhostRequests(c, authzViaQueryString)
 }
 func authzViaQueryString(r *http.Request, tok string) int {
        r.URL.RawQuery = "api_token=" + tok
@@ -86,7 +153,7 @@ func authzViaQueryString(r *http.Request, tok string) int {
 }
 
 func (s *IntegrationSuite) TestVhostViaPOST(c *check.C) {
-       doVhostRequests(c, authzViaPOST)
+       s.doVhostRequests(c, authzViaPOST)
 }
 func authzViaPOST(r *http.Request, tok string) int {
        r.Method = "POST"
@@ -97,7 +164,7 @@ func authzViaPOST(r *http.Request, tok string) int {
 }
 
 func (s *IntegrationSuite) TestVhostViaXHRPOST(c *check.C) {
-       doVhostRequests(c, authzViaPOST)
+       s.doVhostRequests(c, authzViaPOST)
 }
 func authzViaXHRPOST(r *http.Request, tok string) int {
        r.Method = "POST"
@@ -113,7 +180,7 @@ func authzViaXHRPOST(r *http.Request, tok string) int {
 
 // Try some combinations of {url, token} using the given authorization
 // mechanism, and verify the result is correct.
-func doVhostRequests(c *check.C, authz authorizer) {
+func (s *IntegrationSuite) doVhostRequests(c *check.C, authz authorizer) {
        for _, hostPath := range []string{
                arvadostest.FooCollection + ".example.com/foo",
                arvadostest.FooCollection + "--collections.example.com/foo",
@@ -123,11 +190,11 @@ func doVhostRequests(c *check.C, authz authorizer) {
                arvadostest.FooBarDirCollection + ".example.com/dir1/foo",
        } {
                c.Log("doRequests: ", hostPath)
-               doVhostRequestsWithHostPath(c, authz, hostPath)
+               s.doVhostRequestsWithHostPath(c, authz, hostPath)
        }
 }
 
-func doVhostRequestsWithHostPath(c *check.C, authz authorizer, hostPath string) {
+func (s *IntegrationSuite) doVhostRequestsWithHostPath(c *check.C, authz authorizer, hostPath string) {
        for _, tok := range []string{
                arvadostest.ActiveToken,
                arvadostest.ActiveToken[:15],
@@ -144,7 +211,7 @@ func doVhostRequestsWithHostPath(c *check.C, authz authorizer, hostPath string)
                        Header:     http.Header{},
                }
                failCode := authz(req, tok)
-               req, resp := doReq(req)
+               req, resp := s.doReq(req)
                code, body := resp.Code, resp.Body.String()
 
                // If the initial request had a (non-empty) token
@@ -173,9 +240,9 @@ func doVhostRequestsWithHostPath(c *check.C, authz authorizer, hostPath string)
        }
 }
 
-func doReq(req *http.Request) (*http.Request, *httptest.ResponseRecorder) {
+func (s *IntegrationSuite) doReq(req *http.Request) (*http.Request, *httptest.ResponseRecorder) {
        resp := httptest.NewRecorder()
-       (&handler{}).ServeHTTP(resp, req)
+       s.testServer.Handler.ServeHTTP(resp, req)
        if resp.Code != http.StatusSeeOther {
                return req, resp
        }
@@ -191,7 +258,7 @@ func doReq(req *http.Request) (*http.Request, *httptest.ResponseRecorder) {
        for _, c := range cookies {
                req.AddCookie(c)
        }
-       return doReq(req)
+       return s.doReq(req)
 }
 
 func (s *IntegrationSuite) TestVhostRedirectQueryTokenToCookie(c *check.C) {
@@ -270,10 +337,7 @@ func (s *IntegrationSuite) TestVhostRedirectQueryTokenRequestAttachment(c *check
 }
 
 func (s *IntegrationSuite) TestVhostRedirectQueryTokenTrustAllContent(c *check.C) {
-       defer func(orig bool) {
-               trustAllContent = orig
-       }(trustAllContent)
-       trustAllContent = true
+       s.testServer.Config.TrustAllContent = true
        s.testVhostRedirectTokenToCookie(c, "GET",
                "example.com/c="+arvadostest.FooCollection+"/foo",
                "?api_token="+arvadostest.ActiveToken,
@@ -285,10 +349,7 @@ func (s *IntegrationSuite) TestVhostRedirectQueryTokenTrustAllContent(c *check.C
 }
 
 func (s *IntegrationSuite) TestVhostRedirectQueryTokenAttachmentOnlyHost(c *check.C) {
-       defer func(orig string) {
-               attachmentOnlyHost = orig
-       }(attachmentOnlyHost)
-       attachmentOnlyHost = "example.com:1234"
+       s.testServer.Config.AttachmentOnlyHost = "example.com:1234"
 
        s.testVhostRedirectTokenToCookie(c, "GET",
                "example.com/c="+arvadostest.FooCollection+"/foo",
@@ -333,7 +394,7 @@ func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie404(c *check.C)
 }
 
 func (s *IntegrationSuite) TestAnonymousTokenOK(c *check.C) {
-       anonymousTokens = []string{arvadostest.AnonymousToken}
+       s.testServer.Config.AnonymousTokens = []string{arvadostest.AnonymousToken}
        s.testVhostRedirectTokenToCookie(c, "GET",
                "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt",
                "",
@@ -345,7 +406,7 @@ func (s *IntegrationSuite) TestAnonymousTokenOK(c *check.C) {
 }
 
 func (s *IntegrationSuite) TestAnonymousTokenError(c *check.C) {
-       anonymousTokens = []string{"anonymousTokenConfiguredButInvalid"}
+       s.testServer.Config.AnonymousTokens = []string{"anonymousTokenConfiguredButInvalid"}
        s.testVhostRedirectTokenToCookie(c, "GET",
                "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt",
                "",
@@ -356,48 +417,6 @@ func (s *IntegrationSuite) TestAnonymousTokenError(c *check.C) {
        )
 }
 
-func (s *IntegrationSuite) TestRange(c *check.C) {
-       u, _ := url.Parse("http://example.com/c=" + arvadostest.HelloWorldCollection + "/Hello%20world.txt")
-       req := &http.Request{
-               Method:     "GET",
-               Host:       u.Host,
-               URL:        u,
-               RequestURI: u.RequestURI(),
-               Header:     http.Header{"Range": {"bytes=0-4"}},
-       }
-       resp := httptest.NewRecorder()
-       (&handler{}).ServeHTTP(resp, req)
-       c.Check(resp.Code, check.Equals, http.StatusPartialContent)
-       c.Check(resp.Body.String(), check.Equals, "Hello")
-       c.Check(resp.Header().Get("Content-Length"), check.Equals, "5")
-       c.Check(resp.Header().Get("Content-Range"), check.Equals, "bytes 0-4/12")
-
-       req.Header.Set("Range", "bytes=0-")
-       resp = httptest.NewRecorder()
-       (&handler{}).ServeHTTP(resp, req)
-       // 200 and 206 are both correct:
-       c.Check(resp.Code, check.Equals, http.StatusOK)
-       c.Check(resp.Body.String(), check.Equals, "Hello world\n")
-       c.Check(resp.Header().Get("Content-Length"), check.Equals, "12")
-
-       // Unsupported ranges are ignored
-       for _, hdr := range []string{
-               "bytes=5-5",  // non-zero start byte
-               "bytes=-5",   // last 5 bytes
-               "cubits=0-5", // unsupported unit
-               "bytes=0-340282366920938463463374607431768211456", // 2^128
-       } {
-               req.Header.Set("Range", hdr)
-               resp = httptest.NewRecorder()
-               (&handler{}).ServeHTTP(resp, req)
-               c.Check(resp.Code, check.Equals, http.StatusOK)
-               c.Check(resp.Body.String(), check.Equals, "Hello world\n")
-               c.Check(resp.Header().Get("Content-Length"), check.Equals, "12")
-               c.Check(resp.Header().Get("Content-Range"), check.Equals, "")
-               c.Check(resp.Header().Get("Accept-Ranges"), check.Equals, "bytes")
-       }
-}
-
 // XHRs can't follow redirect-with-cookie so they rely on method=POST
 // and disposition=attachment (telling us it's acceptable to respond
 // with content instead of a redirect) and an Origin header that gets
@@ -420,7 +439,7 @@ func (s *IntegrationSuite) TestXHRNoRedirect(c *check.C) {
                }.Encode())),
        }
        resp := httptest.NewRecorder()
-       (&handler{}).ServeHTTP(resp, req)
+       s.testServer.Handler.ServeHTTP(resp, req)
        c.Check(resp.Code, check.Equals, http.StatusOK)
        c.Check(resp.Body.String(), check.Equals, "foo")
        c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, "*")
@@ -443,7 +462,7 @@ func (s *IntegrationSuite) testVhostRedirectTokenToCookie(c *check.C, method, ho
                c.Check(resp.Body.String(), check.Equals, expectRespBody)
        }()
 
-       (&handler{}).ServeHTTP(resp, req)
+       s.testServer.Handler.ServeHTTP(resp, req)
        if resp.Code != http.StatusSeeOther {
                return resp
        }
@@ -463,7 +482,181 @@ func (s *IntegrationSuite) testVhostRedirectTokenToCookie(c *check.C, method, ho
        }
 
        resp = httptest.NewRecorder()
-       (&handler{}).ServeHTTP(resp, req)
+       s.testServer.Handler.ServeHTTP(resp, req)
        c.Check(resp.Header().Get("Location"), check.Equals, "")
        return resp
 }
+
+func (s *IntegrationSuite) TestDirectoryListing(c *check.C) {
+       s.testServer.Config.AttachmentOnlyHost = "download.example.com"
+       authHeader := http.Header{
+               "Authorization": {"OAuth2 " + arvadostest.ActiveToken},
+       }
+       for _, trial := range []struct {
+               uri     string
+               header  http.Header
+               expect  []string
+               cutDirs int
+       }{
+               {
+                       uri:     strings.Replace(arvadostest.FooAndBarFilesInDirPDH, "+", "-", -1) + ".example.com/",
+                       header:  authHeader,
+                       expect:  []string{"dir1/foo", "dir1/bar"},
+                       cutDirs: 0,
+               },
+               {
+                       uri:     strings.Replace(arvadostest.FooAndBarFilesInDirPDH, "+", "-", -1) + ".example.com/dir1/",
+                       header:  authHeader,
+                       expect:  []string{"foo", "bar"},
+                       cutDirs: 0,
+               },
+               {
+                       uri:     "download.example.com/collections/" + arvadostest.FooAndBarFilesInDirUUID + "/",
+                       header:  authHeader,
+                       expect:  []string{"dir1/foo", "dir1/bar"},
+                       cutDirs: 2,
+               },
+               {
+                       uri:     "collections.example.com/collections/download/" + arvadostest.FooAndBarFilesInDirUUID + "/" + arvadostest.ActiveToken + "/",
+                       header:  nil,
+                       expect:  []string{"dir1/foo", "dir1/bar"},
+                       cutDirs: 4,
+               },
+               {
+                       uri:     "collections.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/t=" + arvadostest.ActiveToken + "/",
+                       header:  nil,
+                       expect:  []string{"dir1/foo", "dir1/bar"},
+                       cutDirs: 2,
+               },
+               {
+                       uri:     "collections.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/t=" + arvadostest.ActiveToken,
+                       header:  nil,
+                       expect:  []string{"dir1/foo", "dir1/bar"},
+                       cutDirs: 2,
+               },
+               {
+                       uri:     "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID,
+                       header:  authHeader,
+                       expect:  []string{"dir1/foo", "dir1/bar"},
+                       cutDirs: 1,
+               },
+               {
+                       uri:     "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/dir1/",
+                       header:  authHeader,
+                       expect:  []string{"foo", "bar"},
+                       cutDirs: 1,
+               },
+               {
+                       uri:     "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/_/dir1/",
+                       header:  authHeader,
+                       expect:  []string{"foo", "bar"},
+                       cutDirs: 2,
+               },
+               {
+                       uri:     arvadostest.FooAndBarFilesInDirUUID + ".example.com/dir1?api_token=" + arvadostest.ActiveToken,
+                       header:  authHeader,
+                       expect:  []string{"foo", "bar"},
+                       cutDirs: 0,
+               },
+               {
+                       uri:    "collections.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/theperthcountyconspiracydoesnotexist/",
+                       header: authHeader,
+                       expect: nil,
+               },
+       } {
+               c.Logf("HTML: %q => %q", trial.uri, trial.expect)
+               resp := httptest.NewRecorder()
+               u := mustParseURL("//" + trial.uri)
+               req := &http.Request{
+                       Method:     "GET",
+                       Host:       u.Host,
+                       URL:        u,
+                       RequestURI: u.RequestURI(),
+                       Header:     trial.header,
+               }
+               s.testServer.Handler.ServeHTTP(resp, req)
+               var cookies []*http.Cookie
+               for resp.Code == http.StatusSeeOther {
+                       u, _ := req.URL.Parse(resp.Header().Get("Location"))
+                       req = &http.Request{
+                               Method:     "GET",
+                               Host:       u.Host,
+                               URL:        u,
+                               RequestURI: u.RequestURI(),
+                               Header:     trial.header,
+                       }
+                       cookies = append(cookies, (&http.Response{Header: resp.Header()}).Cookies()...)
+                       for _, c := range cookies {
+                               req.AddCookie(c)
+                       }
+                       resp = httptest.NewRecorder()
+                       s.testServer.Handler.ServeHTTP(resp, req)
+               }
+               if trial.expect == nil {
+                       c.Check(resp.Code, check.Equals, http.StatusNotFound)
+               } else {
+                       c.Check(resp.Code, check.Equals, http.StatusOK)
+                       for _, e := range trial.expect {
+                               c.Check(resp.Body.String(), check.Matches, `(?ms).*href="`+e+`".*`)
+                       }
+                       c.Check(resp.Body.String(), check.Matches, `(?ms).*--cut-dirs=`+fmt.Sprintf("%d", trial.cutDirs)+` .*`)
+               }
+
+               c.Logf("WebDAV: %q => %q", trial.uri, trial.expect)
+               req = &http.Request{
+                       Method:     "OPTIONS",
+                       Host:       u.Host,
+                       URL:        u,
+                       RequestURI: u.RequestURI(),
+                       Header:     trial.header,
+                       Body:       ioutil.NopCloser(&bytes.Buffer{}),
+               }
+               resp = httptest.NewRecorder()
+               s.testServer.Handler.ServeHTTP(resp, req)
+               if trial.expect == nil {
+                       c.Check(resp.Code, check.Equals, http.StatusNotFound)
+               } else {
+                       c.Check(resp.Code, check.Equals, http.StatusOK)
+               }
+
+               req = &http.Request{
+                       Method:     "PROPFIND",
+                       Host:       u.Host,
+                       URL:        u,
+                       RequestURI: u.RequestURI(),
+                       Header:     trial.header,
+                       Body:       ioutil.NopCloser(&bytes.Buffer{}),
+               }
+               resp = httptest.NewRecorder()
+               s.testServer.Handler.ServeHTTP(resp, req)
+               if trial.expect == nil {
+                       c.Check(resp.Code, check.Equals, http.StatusNotFound)
+               } else {
+                       c.Check(resp.Code, check.Equals, http.StatusMultiStatus)
+                       for _, e := range trial.expect {
+                               c.Check(resp.Body.String(), check.Matches, `(?ms).*<D:href>`+filepath.Join(u.Path, e)+`</D:href>.*`)
+                       }
+               }
+       }
+}
+
+func (s *IntegrationSuite) TestHealthCheckPing(c *check.C) {
+       s.testServer.Config.ManagementToken = arvadostest.ManagementToken
+       authHeader := http.Header{
+               "Authorization": {"Bearer " + arvadostest.ManagementToken},
+       }
+
+       resp := httptest.NewRecorder()
+       u := mustParseURL("http://download.example.com/_health/ping")
+       req := &http.Request{
+               Method:     "GET",
+               Host:       u.Host,
+               URL:        u,
+               RequestURI: u.RequestURI(),
+               Header:     authHeader,
+       }
+       s.testServer.Handler.ServeHTTP(resp, req)
+
+       c.Check(resp.Code, check.Equals, http.StatusOK)
+       c.Check(resp.Body.String(), check.Matches, `{"health":"OK"}\n`)
+}