github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/featuretests/tools_test.go (about) 1 // Copyright 2018 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 package featuretests 5 6 import ( 7 "crypto/sha256" 8 "encoding/json" 9 "fmt" 10 "io" 11 "net/http" 12 "net/url" 13 "strings" 14 15 "github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery" 16 "github.com/go-macaroon-bakery/macaroon-bakery/v3/httpbakery" 17 "github.com/juju/errors" 18 jujuhttp "github.com/juju/http/v2" 19 "github.com/juju/names/v5" 20 jc "github.com/juju/testing/checkers" 21 "github.com/juju/version/v2" 22 gc "gopkg.in/check.v1" 23 24 apiauthentication "github.com/juju/juju/api/authentication" 25 apitesting "github.com/juju/juju/api/testing" 26 servertesting "github.com/juju/juju/apiserver/testing" 27 envtesting "github.com/juju/juju/environs/testing" 28 envtools "github.com/juju/juju/environs/tools" 29 jujutesting "github.com/juju/juju/juju/testing" 30 "github.com/juju/juju/rpc/params" 31 "github.com/juju/juju/state" 32 "github.com/juju/juju/state/binarystorage" 33 "github.com/juju/juju/testing" 34 "github.com/juju/juju/testing/factory" 35 coretools "github.com/juju/juju/tools" 36 jujuversion "github.com/juju/juju/version" 37 ) 38 39 type toolsCommonSuite struct { 40 baseURL *url.URL 41 modelUUID string 42 } 43 44 func (s *toolsCommonSuite) toolsURL(query string) *url.URL { 45 return s.modelToolsURL(s.modelUUID, query) 46 } 47 48 func (s *toolsCommonSuite) toolsURI(query string) string { 49 if query != "" && query[0] == '?' { 50 query = query[1:] 51 } 52 return s.toolsURL(query).String() 53 } 54 55 func (s *toolsCommonSuite) modelToolsURL(model, query string) *url.URL { 56 u := s.URL(fmt.Sprintf("/model/%s/tools", model), nil) 57 u.RawQuery = query 58 return u 59 } 60 61 func (s *toolsCommonSuite) assertJSONErrorResponse(c *gc.C, resp *http.Response, expCode int, expError string) { 62 toolsResponse := assertResponse(c, resp, expCode) 63 c.Check(toolsResponse.ToolsList, gc.IsNil) 64 c.Check(toolsResponse.Error, gc.NotNil) 65 c.Check(toolsResponse.Error.Message, gc.Matches, expError) 66 } 67 68 // URL returns a URL for this server with the given path and 69 // query parameters. The URL scheme will be "https". 70 func (s *toolsCommonSuite) URL(path string, queryParams url.Values) *url.URL { 71 url := *s.baseURL 72 url.Path = path 73 url.RawQuery = queryParams.Encode() 74 return &url 75 } 76 77 type toolsDownloadSuite struct { 78 toolsCommonSuite 79 jujutesting.JujuConnSuite 80 } 81 82 func (s *toolsDownloadSuite) SetUpTest(c *gc.C) { 83 s.JujuConnSuite.SetUpTest(c) 84 apiInfo := s.APIInfo(c) 85 baseURL, err := url.Parse(fmt.Sprintf("https://%s/", apiInfo.Addrs[0])) 86 c.Assert(err, jc.ErrorIsNil) 87 s.baseURL = baseURL 88 s.modelUUID = s.Model.UUID() 89 } 90 91 func (s *toolsDownloadSuite) TestDownloadFetchesAndCaches(c *gc.C) { 92 // The tools are not in binarystorage, so the download request causes 93 // the API server to search for the tools in simplestreams, fetch 94 // them, and then cache them in binarystorage. 95 vers := version.MustParseBinary("1.23.0-ubuntu-amd64") 96 stor := s.DefaultToolsStorage 97 envtesting.RemoveTools(c, stor, "released") 98 tools := envtesting.AssertUploadFakeToolsVersions(c, stor, "released", "released", vers)[0] 99 data := s.testDownload(c, tools, "") 100 101 metadata, cachedData := s.getToolsFromStorage(c, s.State, tools.Version.String()) 102 c.Assert(metadata.Size, gc.Equals, tools.Size) 103 c.Assert(metadata.SHA256, gc.Equals, tools.SHA256) 104 c.Assert(string(cachedData), gc.Equals, string(data)) 105 } 106 107 func (s *toolsDownloadSuite) TestDownloadFetchesAndVerifiesSize(c *gc.C) { 108 // Upload fake tools, then upload over the top so the SHA256 hash does not match. 109 s.PatchValue(&jujuversion.Current, testing.FakeVersionNumber) 110 stor := s.DefaultToolsStorage 111 envtesting.RemoveTools(c, stor, "released") 112 current := testing.CurrentVersion() 113 tools := envtesting.AssertUploadFakeToolsVersions(c, stor, "released", "released", current)[0] 114 err := stor.Put(envtools.StorageName(tools.Version, "released"), strings.NewReader("!"), 1) 115 c.Assert(err, jc.ErrorIsNil) 116 117 resp := s.downloadRequest(c, tools.Version, "") 118 s.assertJSONErrorResponse(c, resp, http.StatusBadRequest, "error fetching agent binaries: size mismatch for .*") 119 s.assertToolsNotStored(c, tools.Version.String()) 120 } 121 122 func (s *toolsDownloadSuite) TestDownloadFetchesAndVerifiesHash(c *gc.C) { 123 // Upload fake tools, then upload over the top so the SHA256 hash does not match. 124 s.PatchValue(&jujuversion.Current, testing.FakeVersionNumber) 125 stor := s.DefaultToolsStorage 126 envtesting.RemoveTools(c, stor, "released") 127 current := testing.CurrentVersion() 128 tools := envtesting.AssertUploadFakeToolsVersions(c, stor, "released", "released", current)[0] 129 sameSize := strings.Repeat("!", int(tools.Size)) 130 err := stor.Put(envtools.StorageName(tools.Version, "released"), strings.NewReader(sameSize), tools.Size) 131 c.Assert(err, jc.ErrorIsNil) 132 133 resp := s.downloadRequest(c, tools.Version, "") 134 s.assertJSONErrorResponse(c, resp, http.StatusBadRequest, "error fetching agent binaries: hash mismatch for .*") 135 s.assertToolsNotStored(c, tools.Version.String()) 136 } 137 138 func (s *toolsDownloadSuite) testDownload(c *gc.C, tools *coretools.Tools, uuid string) []byte { 139 resp := s.downloadRequest(c, tools.Version, uuid) 140 defer resp.Body.Close() 141 data, err := io.ReadAll(resp.Body) 142 c.Assert(err, jc.ErrorIsNil) 143 c.Assert(data, gc.HasLen, int(tools.Size)) 144 145 hash := sha256.New() 146 hash.Write(data) 147 c.Assert(fmt.Sprintf("%x", hash.Sum(nil)), gc.Equals, tools.SHA256) 148 return data 149 } 150 151 func (s *toolsDownloadSuite) downloadRequest(c *gc.C, version version.Binary, uuid string) *http.Response { 152 url := s.toolsURL("") 153 if uuid == "" { 154 url.Path = fmt.Sprintf("/tools/%s", version) 155 } else { 156 url.Path = fmt.Sprintf("/model/%s/tools/%s", uuid, version) 157 } 158 return servertesting.SendHTTPRequest(c, servertesting.HTTPRequestParams{Method: "GET", URL: url.String()}) 159 } 160 161 func (s *toolsDownloadSuite) getToolsFromStorage(c *gc.C, st *state.State, vers string) (binarystorage.Metadata, []byte) { 162 storage, err := st.ToolsStorage() 163 c.Assert(err, jc.ErrorIsNil) 164 defer storage.Close() 165 metadata, r, err := storage.Open(vers) 166 c.Assert(err, jc.ErrorIsNil) 167 data, err := io.ReadAll(r) 168 r.Close() 169 c.Assert(err, jc.ErrorIsNil) 170 return metadata, data 171 } 172 173 func (s *toolsDownloadSuite) assertToolsNotStored(c *gc.C, vers string) { 174 storage, err := s.State.ToolsStorage() 175 c.Assert(err, jc.ErrorIsNil) 176 defer storage.Close() 177 _, err = storage.Metadata(vers) 178 c.Assert(err, jc.Satisfies, errors.IsNotFound) 179 } 180 181 func assertResponse(c *gc.C, resp *http.Response, expStatus int) params.ToolsResult { 182 body := servertesting.AssertResponse(c, resp, expStatus, params.ContentTypeJSON) 183 var toolsResponse params.ToolsResult 184 err := json.Unmarshal(body, &toolsResponse) 185 c.Assert(err, jc.ErrorIsNil, gc.Commentf("body: %s", body)) 186 return toolsResponse 187 } 188 189 type toolsWithMacaroonsSuite struct { 190 toolsCommonSuite 191 apitesting.MacaroonSuite 192 userTag names.Tag 193 } 194 195 func (s *toolsWithMacaroonsSuite) SetUpTest(c *gc.C) { 196 s.MacaroonSuite.SetUpTest(c) 197 s.userTag = names.NewUserTag("bob@authhttpsuite") 198 s.AddModelUser(c, s.userTag.Id()) 199 apiInfo := s.APIInfo(c) 200 baseURL, err := url.Parse(fmt.Sprintf("https://%s/", apiInfo.Addrs[0])) 201 c.Assert(err, jc.ErrorIsNil) 202 s.baseURL = baseURL 203 s.modelUUID = s.Model.UUID() 204 } 205 206 func (s *toolsWithMacaroonsSuite) TestWithNoBasicAuthReturnsDischargeRequiredError(c *gc.C) { 207 resp := servertesting.SendHTTPRequest(c, servertesting.HTTPRequestParams{ 208 Method: "POST", 209 URL: s.toolsURI(""), 210 }) 211 212 charmResponse := assertResponse(c, resp, http.StatusUnauthorized) 213 c.Assert(charmResponse.Error, gc.NotNil) 214 c.Assert(charmResponse.Error.Message, gc.Equals, "macaroon discharge required: authentication required") 215 c.Assert(charmResponse.Error.Code, gc.Equals, params.CodeDischargeRequired) 216 c.Assert(charmResponse.Error.Info, gc.NotNil) 217 c.Assert(charmResponse.Error.Info["bakery-macaroon"], gc.NotNil) 218 } 219 220 func (s *toolsWithMacaroonsSuite) TestCanPostWithDischargedMacaroon(c *gc.C) { 221 checkCount := 0 222 s.DischargerLogin = func() string { 223 checkCount++ 224 return s.userTag.Id() 225 } 226 resp := servertesting.SendHTTPRequest(c, servertesting.HTTPRequestParams{ 227 Do: s.doer(), 228 Method: "POST", 229 URL: s.toolsURI(""), 230 }) 231 s.assertJSONErrorResponse(c, resp, http.StatusBadRequest, "expected binaryVersion argument") 232 c.Assert(checkCount, gc.Equals, 1) 233 } 234 235 func (s *toolsWithMacaroonsSuite) TestCanPostWithLocalLogin(c *gc.C) { 236 // Create a new local user that we can log in as 237 // using macaroon authentication. 238 const password = "hunter2" 239 user := s.Factory.MakeUser(c, &factory.UserParams{Password: password}) 240 241 // Install a "web-page" visitor that deals with the interaction 242 // method that Juju controllers support for authenticating local 243 // users. Note: the use of httpbakery.NewMultiVisitor is necessary 244 // to trigger httpbakery to query the authentication methods and 245 // bypass browser authentication. 246 var prompted bool 247 bakeryClient := httpbakery.NewClient() 248 jar := apitesting.NewClearableCookieJar() 249 client := jujuhttp.NewClient( 250 jujuhttp.WithSkipHostnameVerification(true), 251 jujuhttp.WithCookieJar(jar), 252 ) 253 bakeryClient.Client = client.Client() 254 bakeryClient.AddInteractor(apiauthentication.NewInteractor( 255 user.UserTag().Id(), 256 func(username string) (string, error) { 257 c.Assert(username, gc.Equals, user.UserTag().Id()) 258 prompted = true 259 return password, nil 260 }, 261 )) 262 bakeryDo := func(req *http.Request) (*http.Response, error) { 263 c.Logf("req.URL: %#v", req.URL) 264 return bakeryClient.DoWithCustomError(req, bakeryGetError) 265 } 266 267 resp := servertesting.SendHTTPRequest(c, servertesting.HTTPRequestParams{ 268 Method: "POST", 269 URL: s.toolsURI(""), 270 Tag: user.UserTag().String(), 271 Password: "", // no password forces macaroon usage 272 Do: bakeryDo, 273 }) 274 s.assertJSONErrorResponse(c, resp, http.StatusBadRequest, "expected binaryVersion argument") 275 c.Assert(prompted, jc.IsTrue) 276 } 277 278 // doer returns a Do function that can make a bakery request 279 // appropriate for a charms endpoint. 280 func (s *toolsWithMacaroonsSuite) doer() func(*http.Request) (*http.Response, error) { 281 return bakeryDo(nil, bakeryGetError) 282 } 283 284 // bakeryDo provides a function suitable for using in HTTPRequestParams.Do 285 // that will use the given http client (or bakery created client with a 286 // non verifying secure TLS config if client is nil) and use the given 287 // getBakeryError function to translate errors in responses. 288 func bakeryDo(client *http.Client, getBakeryError func(*http.Response) error) func(*http.Request) (*http.Response, error) { 289 bclient := httpbakery.NewClient() 290 if client != nil { 291 bclient.Client = client 292 } else { 293 // Configure the default client to skip verification/ 294 tlsConfig := jujuhttp.SecureTLSConfig() 295 tlsConfig.InsecureSkipVerify = true 296 bclient.Client.Transport = jujuhttp.NewHTTPTLSTransport(jujuhttp.TransportConfig{ 297 TLSConfig: tlsConfig, 298 }) 299 } 300 return func(req *http.Request) (*http.Response, error) { 301 return bclient.DoWithCustomError(req, getBakeryError) 302 } 303 } 304 305 // bakeryGetError implements a getError function 306 // appropriate for passing to httpbakery.Client.DoWithBodyAndCustomError 307 // for any endpoint that returns the error in a top level Error field. 308 func bakeryGetError(resp *http.Response) error { 309 if resp.StatusCode != http.StatusUnauthorized { 310 return nil 311 } 312 data, err := io.ReadAll(resp.Body) 313 if err != nil { 314 return errors.Annotatef(err, "cannot read body") 315 } 316 var errResp params.ErrorResult 317 if err := json.Unmarshal(data, &errResp); err != nil { 318 return errors.Annotatef(err, "cannot unmarshal body") 319 } 320 if errResp.Error == nil { 321 return errors.New("no error found in error response body") 322 } 323 if errResp.Error.Code != params.CodeDischargeRequired { 324 return errResp.Error 325 } 326 if errResp.Error.Info == nil { 327 return errors.Annotatef(err, "no error info found in discharge-required response error") 328 } 329 // It's a discharge-required error, so make an appropriate httpbakery 330 // error from it. 331 var info params.DischargeRequiredErrorInfo 332 if errUnmarshal := errResp.Error.UnmarshalInfo(&info); errUnmarshal != nil { 333 return errors.Annotatef(err, "unable to extract macaroon details from discharge-required response error") 334 } 335 336 mac := info.BakeryMacaroon 337 if mac == nil { 338 var err error 339 mac, err = bakery.NewLegacyMacaroon(info.Macaroon) 340 if err != nil { 341 return errors.Trace(err) 342 } 343 } 344 return &httpbakery.Error{ 345 Message: errResp.Error.Message, 346 Code: httpbakery.ErrDischargeRequired, 347 Info: &httpbakery.ErrorInfo{ 348 Macaroon: mac, 349 MacaroonPath: info.MacaroonPath, 350 }, 351 } 352 }