392de94ffb5af1399bada756ed4a8efc7f33506b
[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                 req := &http.Request{
36                         Method: "GET",
37                         URL:    mustParseURL(testURL),
38                 }
39                 (&handler{}).ServeHTTP(resp, req)
40                 c.Check(resp.Code, check.Equals, http.StatusNotFound)
41                 c.Check(resp.Body.String(), check.Equals, "")
42         }
43 }
44
45 // An authorizer modifies an HTTP request to make use of the given
46 // token -- by adding it to a header, cookie, query param, or whatever
47 // -- and returns the HTTP status code we should expect from keep-web if
48 // the token is invalid.
49 type authorizer func(*http.Request, string) int
50
51 func (s *IntegrationSuite) TestVhostViaAuthzHeader(c *check.C) {
52         doVhostRequests(c, authzViaAuthzHeader)
53 }
54 func authzViaAuthzHeader(r *http.Request, tok string) int {
55         r.Header.Add("Authorization", "OAuth2 "+tok)
56         return http.StatusUnauthorized
57 }
58
59 func (s *IntegrationSuite) TestVhostViaCookieValue(c *check.C) {
60         doVhostRequests(c, authzViaCookieValue)
61 }
62 func authzViaCookieValue(r *http.Request, tok string) int {
63         r.AddCookie(&http.Cookie{
64                 Name:  "arvados_api_token",
65                 Value: auth.EncodeTokenCookie([]byte(tok)),
66         })
67         return http.StatusUnauthorized
68 }
69
70 func (s *IntegrationSuite) TestVhostViaPath(c *check.C) {
71         doVhostRequests(c, authzViaPath)
72 }
73 func authzViaPath(r *http.Request, tok string) int {
74         r.URL.Path = "/t=" + tok + r.URL.Path
75         return http.StatusNotFound
76 }
77
78 func (s *IntegrationSuite) TestVhostViaQueryString(c *check.C) {
79         doVhostRequests(c, authzViaQueryString)
80 }
81 func authzViaQueryString(r *http.Request, tok string) int {
82         r.URL.RawQuery = "api_token=" + tok
83         return http.StatusUnauthorized
84 }
85
86 func (s *IntegrationSuite) TestVhostViaPOST(c *check.C) {
87         doVhostRequests(c, authzViaPOST)
88 }
89 func authzViaPOST(r *http.Request, tok string) int {
90         r.Method = "POST"
91         r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
92         r.Body = ioutil.NopCloser(strings.NewReader(
93                 url.Values{"api_token": {tok}}.Encode()))
94         return http.StatusUnauthorized
95 }
96
97 // Try some combinations of {url, token} using the given authorization
98 // mechanism, and verify the result is correct.
99 func doVhostRequests(c *check.C, authz authorizer) {
100         for _, hostPath := range []string{
101                 arvadostest.FooCollection + ".example.com/foo",
102                 arvadostest.FooCollection + "--collections.example.com/foo",
103                 arvadostest.FooCollection + "--collections.example.com/_/foo",
104                 arvadostest.FooPdh + ".example.com/foo",
105                 strings.Replace(arvadostest.FooPdh, "+", "-", -1) + "--collections.example.com/foo",
106                 arvadostest.FooBarDirCollection + ".example.com/dir1/foo",
107         } {
108                 c.Log("doRequests: ", hostPath)
109                 doVhostRequestsWithHostPath(c, authz, hostPath)
110         }
111 }
112
113 func doVhostRequestsWithHostPath(c *check.C, authz authorizer, hostPath string) {
114         for _, tok := range []string{
115                 arvadostest.ActiveToken,
116                 arvadostest.ActiveToken[:15],
117                 arvadostest.SpectatorToken,
118                 "bogus",
119                 "",
120         } {
121                 u := mustParseURL("http://" + hostPath)
122                 req := &http.Request{
123                         Method: "GET",
124                         Host:   u.Host,
125                         URL:    u,
126                         Header: http.Header{},
127                 }
128                 failCode := authz(req, tok)
129                 resp := doReq(req)
130                 code, body := resp.Code, resp.Body.String()
131                 if tok == arvadostest.ActiveToken {
132                         c.Check(code, check.Equals, http.StatusOK)
133                         c.Check(body, check.Equals, "foo")
134                 } else {
135                         c.Check(code >= 400, check.Equals, true)
136                         c.Check(code < 500, check.Equals, true)
137                         if tok == arvadostest.SpectatorToken {
138                                 // Valid token never offers to retry
139                                 // with different credentials.
140                                 c.Check(code, check.Equals, http.StatusNotFound)
141                         } else {
142                                 // Invalid token can ask to retry
143                                 // depending on the authz method.
144                                 c.Check(code, check.Equals, failCode)
145                         }
146                         c.Check(body, check.Equals, "")
147                 }
148         }
149 }
150
151 func doReq(req *http.Request) *httptest.ResponseRecorder {
152         resp := httptest.NewRecorder()
153         (&handler{}).ServeHTTP(resp, req)
154         if resp.Code != http.StatusSeeOther {
155                 return resp
156         }
157         cookies := (&http.Response{Header: resp.Header()}).Cookies()
158         u, _ := req.URL.Parse(resp.Header().Get("Location"))
159         req = &http.Request{
160                 Method: "GET",
161                 Host:   u.Host,
162                 URL:    u,
163                 Header: http.Header{},
164         }
165         for _, c := range cookies {
166                 req.AddCookie(c)
167         }
168         return doReq(req)
169 }
170
171 func (s *IntegrationSuite) TestVhostRedirectQueryTokenToCookie(c *check.C) {
172         s.testVhostRedirectTokenToCookie(c, "GET",
173                 arvadostest.FooCollection+".example.com/foo",
174                 "?api_token="+arvadostest.ActiveToken,
175                 "",
176                 "",
177                 http.StatusOK,
178                 "foo",
179         )
180 }
181
182 func (s *IntegrationSuite) TestSingleOriginSecretLink(c *check.C) {
183         s.testVhostRedirectTokenToCookie(c, "GET",
184                 "example.com/c="+arvadostest.FooCollection+"/t="+arvadostest.ActiveToken+"/foo",
185                 "",
186                 "",
187                 "",
188                 http.StatusOK,
189                 "foo",
190         )
191 }
192
193 // Bad token in URL is 404 Not Found because it doesn't make sense to
194 // retry the same URL with different authorization.
195 func (s *IntegrationSuite) TestSingleOriginSecretLinkBadToken(c *check.C) {
196         s.testVhostRedirectTokenToCookie(c, "GET",
197                 "example.com/c="+arvadostest.FooCollection+"/t=bogus/foo",
198                 "",
199                 "",
200                 "",
201                 http.StatusNotFound,
202                 "",
203         )
204 }
205
206 // Bad token in a cookie (even if it got there via our own
207 // query-string-to-cookie redirect) is, in principle, retryable at the
208 // same URL so it's 401 Unauthorized.
209 func (s *IntegrationSuite) TestVhostRedirectQueryTokenToBogusCookie(c *check.C) {
210         s.testVhostRedirectTokenToCookie(c, "GET",
211                 arvadostest.FooCollection+".example.com/foo",
212                 "?api_token=thisisabogustoken",
213                 "",
214                 "",
215                 http.StatusUnauthorized,
216                 "",
217         )
218 }
219
220 func (s *IntegrationSuite) TestVhostRedirectQueryTokenSingleOriginError(c *check.C) {
221         s.testVhostRedirectTokenToCookie(c, "GET",
222                 "example.com/c="+arvadostest.FooCollection+"/foo",
223                 "?api_token="+arvadostest.ActiveToken,
224                 "",
225                 "",
226                 http.StatusBadRequest,
227                 "",
228         )
229 }
230
231 func (s *IntegrationSuite) TestVhostRedirectQueryTokenTrustAllContent(c *check.C) {
232         defer func(orig bool) {
233                 trustAllContent = orig
234         }(trustAllContent)
235         trustAllContent = true
236         s.testVhostRedirectTokenToCookie(c, "GET",
237                 "example.com/c="+arvadostest.FooCollection+"/foo",
238                 "?api_token="+arvadostest.ActiveToken,
239                 "",
240                 "",
241                 http.StatusOK,
242                 "foo",
243         )
244 }
245
246 func (s *IntegrationSuite) TestVhostRedirectQueryTokenAttachmentOnlyHost(c *check.C) {
247         defer func(orig string) {
248                 attachmentOnlyHost = orig
249         }(attachmentOnlyHost)
250         attachmentOnlyHost = "example.com:1234"
251
252         s.testVhostRedirectTokenToCookie(c, "GET",
253                 "example.com/c="+arvadostest.FooCollection+"/foo",
254                 "?api_token="+arvadostest.ActiveToken,
255                 "",
256                 "",
257                 http.StatusBadRequest,
258                 "",
259         )
260
261         resp := s.testVhostRedirectTokenToCookie(c, "GET",
262                 "example.com:1234/c="+arvadostest.FooCollection+"/foo",
263                 "?api_token="+arvadostest.ActiveToken,
264                 "",
265                 "",
266                 http.StatusOK,
267                 "foo",
268         )
269         c.Check(resp.Header().Get("Content-Disposition"), check.Equals, "attachment")
270 }
271
272 func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie(c *check.C) {
273         s.testVhostRedirectTokenToCookie(c, "POST",
274                 arvadostest.FooCollection+".example.com/foo",
275                 "",
276                 "application/x-www-form-urlencoded",
277                 url.Values{"api_token": {arvadostest.ActiveToken}}.Encode(),
278                 http.StatusOK,
279                 "foo",
280         )
281 }
282
283 func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie404(c *check.C) {
284         s.testVhostRedirectTokenToCookie(c, "POST",
285                 arvadostest.FooCollection+".example.com/foo",
286                 "",
287                 "application/x-www-form-urlencoded",
288                 url.Values{"api_token": {arvadostest.SpectatorToken}}.Encode(),
289                 http.StatusNotFound,
290                 "",
291         )
292 }
293
294 func (s *IntegrationSuite) TestAnonymousTokenOK(c *check.C) {
295         anonymousTokens = []string{arvadostest.AnonymousToken}
296         s.testVhostRedirectTokenToCookie(c, "GET",
297                 "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt",
298                 "",
299                 "",
300                 "",
301                 http.StatusOK,
302                 "Hello world\n",
303         )
304 }
305
306 func (s *IntegrationSuite) TestAnonymousTokenError(c *check.C) {
307         anonymousTokens = []string{"anonymousTokenConfiguredButInvalid"}
308         s.testVhostRedirectTokenToCookie(c, "GET",
309                 "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt",
310                 "",
311                 "",
312                 "",
313                 http.StatusNotFound,
314                 "",
315         )
316 }
317
318 func (s *IntegrationSuite) testVhostRedirectTokenToCookie(c *check.C, method, hostPath, queryString, contentType, reqBody string, expectStatus int, expectRespBody string) *httptest.ResponseRecorder {
319         u, _ := url.Parse(`http://` + hostPath + queryString)
320         req := &http.Request{
321                 Method: method,
322                 Host:   u.Host,
323                 URL:    u,
324                 Header: http.Header{"Content-Type": {contentType}},
325                 Body:   ioutil.NopCloser(strings.NewReader(reqBody)),
326         }
327
328         resp := httptest.NewRecorder()
329         defer func() {
330                 c.Check(resp.Code, check.Equals, expectStatus)
331                 c.Check(resp.Body.String(), check.Equals, expectRespBody)
332         }()
333
334         (&handler{}).ServeHTTP(resp, req)
335         if resp.Code != http.StatusSeeOther {
336                 return resp
337         }
338         c.Check(resp.Body.String(), check.Matches, `.*href="//`+regexp.QuoteMeta(html.EscapeString(hostPath))+`".*`)
339         cookies := (&http.Response{Header: resp.Header()}).Cookies()
340
341         u, _ = u.Parse(resp.Header().Get("Location"))
342         req = &http.Request{
343                 Method: "GET",
344                 Host:   u.Host,
345                 URL:    u,
346                 Header: http.Header{},
347         }
348         for _, c := range cookies {
349                 req.AddCookie(c)
350         }
351
352         resp = httptest.NewRecorder()
353         (&handler{}).ServeHTTP(resp, req)
354         c.Check(resp.Header().Get("Location"), check.Equals, "")
355         return resp
356 }