Merge branch '5824-keep-web-workbench' refs #5824
[arvados.git] / services / keep-web / handler_test.go
1 package main
2
3 import (
4         "html"
5         "io/ioutil"
6         "net/http"
7         "net/http/httptest"
8         "net/url"
9         "regexp"
10         "strings"
11
12         "git.curoverse.com/arvados.git/sdk/go/arvadostest"
13         "git.curoverse.com/arvados.git/sdk/go/auth"
14         check "gopkg.in/check.v1"
15 )
16
17 var _ = check.Suite(&UnitSuite{})
18
19 type UnitSuite struct{}
20
21 func mustParseURL(s string) *url.URL {
22         r, err := url.Parse(s)
23         if err != nil {
24                 panic("parse URL: " + s)
25         }
26         return r
27 }
28
29 func (s *IntegrationSuite) TestVhost404(c *check.C) {
30         for _, testURL := range []string{
31                 arvadostest.NonexistentCollection + ".example.com/theperthcountyconspiracy",
32                 arvadostest.NonexistentCollection + ".example.com/t=" + arvadostest.ActiveToken + "/theperthcountyconspiracy",
33         } {
34                 resp := httptest.NewRecorder()
35                 u := mustParseURL(testURL)
36                 req := &http.Request{
37                         Method:     "GET",
38                         URL:        u,
39                         RequestURI: u.RequestURI(),
40                 }
41                 (&handler{}).ServeHTTP(resp, req)
42                 c.Check(resp.Code, check.Equals, http.StatusNotFound)
43                 c.Check(resp.Body.String(), check.Equals, "")
44         }
45 }
46
47 // An authorizer modifies an HTTP request to make use of the given
48 // token -- by adding it to a header, cookie, query param, or whatever
49 // -- and returns the HTTP status code we should expect from keep-web if
50 // the token is invalid.
51 type authorizer func(*http.Request, string) int
52
53 func (s *IntegrationSuite) TestVhostViaAuthzHeader(c *check.C) {
54         doVhostRequests(c, authzViaAuthzHeader)
55 }
56 func authzViaAuthzHeader(r *http.Request, tok string) int {
57         r.Header.Add("Authorization", "OAuth2 "+tok)
58         return http.StatusUnauthorized
59 }
60
61 func (s *IntegrationSuite) TestVhostViaCookieValue(c *check.C) {
62         doVhostRequests(c, authzViaCookieValue)
63 }
64 func authzViaCookieValue(r *http.Request, tok string) int {
65         r.AddCookie(&http.Cookie{
66                 Name:  "arvados_api_token",
67                 Value: auth.EncodeTokenCookie([]byte(tok)),
68         })
69         return http.StatusUnauthorized
70 }
71
72 func (s *IntegrationSuite) TestVhostViaPath(c *check.C) {
73         doVhostRequests(c, authzViaPath)
74 }
75 func authzViaPath(r *http.Request, tok string) int {
76         r.URL.Path = "/t=" + tok + r.URL.Path
77         return http.StatusNotFound
78 }
79
80 func (s *IntegrationSuite) TestVhostViaQueryString(c *check.C) {
81         doVhostRequests(c, authzViaQueryString)
82 }
83 func authzViaQueryString(r *http.Request, tok string) int {
84         r.URL.RawQuery = "api_token=" + tok
85         return http.StatusUnauthorized
86 }
87
88 func (s *IntegrationSuite) TestVhostViaPOST(c *check.C) {
89         doVhostRequests(c, authzViaPOST)
90 }
91 func authzViaPOST(r *http.Request, tok string) int {
92         r.Method = "POST"
93         r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
94         r.Body = ioutil.NopCloser(strings.NewReader(
95                 url.Values{"api_token": {tok}}.Encode()))
96         return http.StatusUnauthorized
97 }
98
99 // Try some combinations of {url, token} using the given authorization
100 // mechanism, and verify the result is correct.
101 func doVhostRequests(c *check.C, authz authorizer) {
102         for _, hostPath := range []string{
103                 arvadostest.FooCollection + ".example.com/foo",
104                 arvadostest.FooCollection + "--collections.example.com/foo",
105                 arvadostest.FooCollection + "--collections.example.com/_/foo",
106                 arvadostest.FooPdh + ".example.com/foo",
107                 strings.Replace(arvadostest.FooPdh, "+", "-", -1) + "--collections.example.com/foo",
108                 arvadostest.FooBarDirCollection + ".example.com/dir1/foo",
109         } {
110                 c.Log("doRequests: ", hostPath)
111                 doVhostRequestsWithHostPath(c, authz, hostPath)
112         }
113 }
114
115 func doVhostRequestsWithHostPath(c *check.C, authz authorizer, hostPath string) {
116         for _, tok := range []string{
117                 arvadostest.ActiveToken,
118                 arvadostest.ActiveToken[:15],
119                 arvadostest.SpectatorToken,
120                 "bogus",
121                 "",
122         } {
123                 u := mustParseURL("http://" + hostPath)
124                 req := &http.Request{
125                         Method:     "GET",
126                         Host:       u.Host,
127                         URL:        u,
128                         RequestURI: u.RequestURI(),
129                         Header:     http.Header{},
130                 }
131                 failCode := authz(req, tok)
132                 resp := doReq(req)
133                 code, body := resp.Code, resp.Body.String()
134                 if tok == arvadostest.ActiveToken {
135                         c.Check(code, check.Equals, http.StatusOK)
136                         c.Check(body, check.Equals, "foo")
137                 } else {
138                         c.Check(code >= 400, check.Equals, true)
139                         c.Check(code < 500, check.Equals, true)
140                         if tok == arvadostest.SpectatorToken {
141                                 // Valid token never offers to retry
142                                 // with different credentials.
143                                 c.Check(code, check.Equals, http.StatusNotFound)
144                         } else {
145                                 // Invalid token can ask to retry
146                                 // depending on the authz method.
147                                 c.Check(code, check.Equals, failCode)
148                         }
149                         c.Check(body, check.Equals, "")
150                 }
151         }
152 }
153
154 func doReq(req *http.Request) *httptest.ResponseRecorder {
155         resp := httptest.NewRecorder()
156         (&handler{}).ServeHTTP(resp, req)
157         if resp.Code != http.StatusSeeOther {
158                 return resp
159         }
160         cookies := (&http.Response{Header: resp.Header()}).Cookies()
161         u, _ := req.URL.Parse(resp.Header().Get("Location"))
162         req = &http.Request{
163                 Method:     "GET",
164                 Host:       u.Host,
165                 URL:        u,
166                 RequestURI: u.RequestURI(),
167                 Header:     http.Header{},
168         }
169         for _, c := range cookies {
170                 req.AddCookie(c)
171         }
172         return doReq(req)
173 }
174
175 func (s *IntegrationSuite) TestVhostRedirectQueryTokenToCookie(c *check.C) {
176         s.testVhostRedirectTokenToCookie(c, "GET",
177                 arvadostest.FooCollection+".example.com/foo",
178                 "?api_token="+arvadostest.ActiveToken,
179                 "",
180                 "",
181                 http.StatusOK,
182                 "foo",
183         )
184 }
185
186 func (s *IntegrationSuite) TestSingleOriginSecretLink(c *check.C) {
187         s.testVhostRedirectTokenToCookie(c, "GET",
188                 "example.com/c="+arvadostest.FooCollection+"/t="+arvadostest.ActiveToken+"/foo",
189                 "",
190                 "",
191                 "",
192                 http.StatusOK,
193                 "foo",
194         )
195 }
196
197 // Bad token in URL is 404 Not Found because it doesn't make sense to
198 // retry the same URL with different authorization.
199 func (s *IntegrationSuite) TestSingleOriginSecretLinkBadToken(c *check.C) {
200         s.testVhostRedirectTokenToCookie(c, "GET",
201                 "example.com/c="+arvadostest.FooCollection+"/t=bogus/foo",
202                 "",
203                 "",
204                 "",
205                 http.StatusNotFound,
206                 "",
207         )
208 }
209
210 // Bad token in a cookie (even if it got there via our own
211 // query-string-to-cookie redirect) is, in principle, retryable at the
212 // same URL so it's 401 Unauthorized.
213 func (s *IntegrationSuite) TestVhostRedirectQueryTokenToBogusCookie(c *check.C) {
214         s.testVhostRedirectTokenToCookie(c, "GET",
215                 arvadostest.FooCollection+".example.com/foo",
216                 "?api_token=thisisabogustoken",
217                 "",
218                 "",
219                 http.StatusUnauthorized,
220                 "",
221         )
222 }
223
224 func (s *IntegrationSuite) TestVhostRedirectQueryTokenSingleOriginError(c *check.C) {
225         s.testVhostRedirectTokenToCookie(c, "GET",
226                 "example.com/c="+arvadostest.FooCollection+"/foo",
227                 "?api_token="+arvadostest.ActiveToken,
228                 "",
229                 "",
230                 http.StatusBadRequest,
231                 "",
232         )
233 }
234
235 // If client requests an attachment by putting ?disposition=attachment
236 // in the query string, and gets redirected, the redirect target
237 // should respond with an attachment.
238 func (s *IntegrationSuite) TestVhostRedirectQueryTokenRequestAttachment(c *check.C) {
239         resp := s.testVhostRedirectTokenToCookie(c, "GET",
240                 arvadostest.FooCollection+".example.com/foo",
241                 "?disposition=attachment&api_token="+arvadostest.ActiveToken,
242                 "",
243                 "",
244                 http.StatusOK,
245                 "foo",
246         )
247         c.Check(strings.Split(resp.Header().Get("Content-Disposition"), ";")[0], check.Equals, "attachment")
248 }
249
250 func (s *IntegrationSuite) TestVhostRedirectQueryTokenTrustAllContent(c *check.C) {
251         defer func(orig bool) {
252                 trustAllContent = orig
253         }(trustAllContent)
254         trustAllContent = true
255         s.testVhostRedirectTokenToCookie(c, "GET",
256                 "example.com/c="+arvadostest.FooCollection+"/foo",
257                 "?api_token="+arvadostest.ActiveToken,
258                 "",
259                 "",
260                 http.StatusOK,
261                 "foo",
262         )
263 }
264
265 func (s *IntegrationSuite) TestVhostRedirectQueryTokenAttachmentOnlyHost(c *check.C) {
266         defer func(orig string) {
267                 attachmentOnlyHost = orig
268         }(attachmentOnlyHost)
269         attachmentOnlyHost = "example.com:1234"
270
271         s.testVhostRedirectTokenToCookie(c, "GET",
272                 "example.com/c="+arvadostest.FooCollection+"/foo",
273                 "?api_token="+arvadostest.ActiveToken,
274                 "",
275                 "",
276                 http.StatusBadRequest,
277                 "",
278         )
279
280         resp := s.testVhostRedirectTokenToCookie(c, "GET",
281                 "example.com:1234/c="+arvadostest.FooCollection+"/foo",
282                 "?api_token="+arvadostest.ActiveToken,
283                 "",
284                 "",
285                 http.StatusOK,
286                 "foo",
287         )
288         c.Check(resp.Header().Get("Content-Disposition"), check.Equals, "attachment")
289 }
290
291 func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie(c *check.C) {
292         s.testVhostRedirectTokenToCookie(c, "POST",
293                 arvadostest.FooCollection+".example.com/foo",
294                 "",
295                 "application/x-www-form-urlencoded",
296                 url.Values{"api_token": {arvadostest.ActiveToken}}.Encode(),
297                 http.StatusOK,
298                 "foo",
299         )
300 }
301
302 func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie404(c *check.C) {
303         s.testVhostRedirectTokenToCookie(c, "POST",
304                 arvadostest.FooCollection+".example.com/foo",
305                 "",
306                 "application/x-www-form-urlencoded",
307                 url.Values{"api_token": {arvadostest.SpectatorToken}}.Encode(),
308                 http.StatusNotFound,
309                 "",
310         )
311 }
312
313 func (s *IntegrationSuite) TestAnonymousTokenOK(c *check.C) {
314         anonymousTokens = []string{arvadostest.AnonymousToken}
315         s.testVhostRedirectTokenToCookie(c, "GET",
316                 "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt",
317                 "",
318                 "",
319                 "",
320                 http.StatusOK,
321                 "Hello world\n",
322         )
323 }
324
325 func (s *IntegrationSuite) TestAnonymousTokenError(c *check.C) {
326         anonymousTokens = []string{"anonymousTokenConfiguredButInvalid"}
327         s.testVhostRedirectTokenToCookie(c, "GET",
328                 "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt",
329                 "",
330                 "",
331                 "",
332                 http.StatusNotFound,
333                 "",
334         )
335 }
336
337 func (s *IntegrationSuite) TestRange(c *check.C) {
338         u, _ := url.Parse("http://example.com/c=" + arvadostest.HelloWorldCollection + "/Hello%20world.txt")
339         req := &http.Request{
340                 Method:     "GET",
341                 Host:       u.Host,
342                 URL:        u,
343                 RequestURI: u.RequestURI(),
344                 Header:     http.Header{"Range": {"bytes=0-4"}},
345         }
346         resp := httptest.NewRecorder()
347         (&handler{}).ServeHTTP(resp, req)
348         c.Check(resp.Code, check.Equals, http.StatusPartialContent)
349         c.Check(resp.Body.String(), check.Equals, "Hello")
350         c.Check(resp.Header().Get("Content-Length"), check.Equals, "5")
351         c.Check(resp.Header().Get("Content-Range"), check.Equals, "bytes 0-4/12")
352
353         req.Header.Set("Range", "bytes=0-")
354         resp = httptest.NewRecorder()
355         (&handler{}).ServeHTTP(resp, req)
356         // 200 and 206 are both correct:
357         c.Check(resp.Code, check.Equals, http.StatusOK)
358         c.Check(resp.Body.String(), check.Equals, "Hello world\n")
359         c.Check(resp.Header().Get("Content-Length"), check.Equals, "12")
360
361         // Unsupported ranges are ignored
362         for _, hdr := range []string{
363                 "bytes=5-5",  // non-zero start byte
364                 "bytes=-5",   // last 5 bytes
365                 "cubits=0-5", // unsupported unit
366                 "bytes=0-340282366920938463463374607431768211456", // 2^128
367         } {
368                 req.Header.Set("Range", hdr)
369                 resp = httptest.NewRecorder()
370                 (&handler{}).ServeHTTP(resp, req)
371                 c.Check(resp.Code, check.Equals, http.StatusOK)
372                 c.Check(resp.Body.String(), check.Equals, "Hello world\n")
373                 c.Check(resp.Header().Get("Content-Length"), check.Equals, "12")
374                 c.Check(resp.Header().Get("Content-Range"), check.Equals, "")
375                 c.Check(resp.Header().Get("Accept-Ranges"), check.Equals, "bytes")
376         }
377 }
378
379 func (s *IntegrationSuite) testVhostRedirectTokenToCookie(c *check.C, method, hostPath, queryString, contentType, reqBody string, expectStatus int, expectRespBody string) *httptest.ResponseRecorder {
380         u, _ := url.Parse(`http://` + hostPath + queryString)
381         req := &http.Request{
382                 Method:     method,
383                 Host:       u.Host,
384                 URL:        u,
385                 RequestURI: u.RequestURI(),
386                 Header:     http.Header{"Content-Type": {contentType}},
387                 Body:       ioutil.NopCloser(strings.NewReader(reqBody)),
388         }
389
390         resp := httptest.NewRecorder()
391         defer func() {
392                 c.Check(resp.Code, check.Equals, expectStatus)
393                 c.Check(resp.Body.String(), check.Equals, expectRespBody)
394         }()
395
396         (&handler{}).ServeHTTP(resp, req)
397         if resp.Code != http.StatusSeeOther {
398                 return resp
399         }
400         c.Check(resp.Body.String(), check.Matches, `.*href="//`+regexp.QuoteMeta(html.EscapeString(hostPath))+`(\?[^"]*)?".*`)
401         cookies := (&http.Response{Header: resp.Header()}).Cookies()
402
403         u, _ = u.Parse(resp.Header().Get("Location"))
404         req = &http.Request{
405                 Method:     "GET",
406                 Host:       u.Host,
407                 URL:        u,
408                 RequestURI: u.RequestURI(),
409                 Header:     http.Header{},
410         }
411         for _, c := range cookies {
412                 req.AddCookie(c)
413         }
414
415         resp = httptest.NewRecorder()
416         (&handler{}).ServeHTTP(resp, req)
417         c.Check(resp.Header().Get("Location"), check.Equals, "")
418         return resp
419 }