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  }