github.com/juju/juju@v0.0.0-20240327075706-a90865de2538/api/httpclient_test.go (about)

     1  // Copyright 2015 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package api_test
     5  
     6  import (
     7  	"context"
     8  	"net/http"
     9  	"net/http/httptest"
    10  	"reflect"
    11  
    12  	"github.com/go-macaroon-bakery/macaroon-bakery/v3/httpbakery"
    13  	"github.com/juju/errors"
    14  	"github.com/juju/names/v5"
    15  	jc "github.com/juju/testing/checkers"
    16  	gc "gopkg.in/check.v1"
    17  	"gopkg.in/httprequest.v1"
    18  	"gopkg.in/macaroon.v2"
    19  
    20  	"github.com/juju/juju/api"
    21  	apitesting "github.com/juju/juju/api/testing"
    22  	jujutesting "github.com/juju/juju/juju/testing"
    23  	"github.com/juju/juju/rpc/params"
    24  	"github.com/juju/juju/state"
    25  	"github.com/juju/juju/testing/factory"
    26  	"github.com/juju/juju/version"
    27  )
    28  
    29  type httpSuite struct {
    30  	jujutesting.JujuConnSuite
    31  
    32  	client *httprequest.Client
    33  }
    34  
    35  var _ = gc.Suite(&httpSuite{})
    36  
    37  func (s *httpSuite) SetUpTest(c *gc.C) {
    38  	s.JujuConnSuite.SetUpTest(c)
    39  
    40  	client, err := s.APIState.HTTPClient()
    41  	c.Assert(err, gc.IsNil)
    42  	s.client = client
    43  }
    44  
    45  var httpClientTests = []struct {
    46  	about           string
    47  	handler         http.HandlerFunc
    48  	expectResponse  interface{}
    49  	expectError     string
    50  	expectErrorIs   errors.ConstError
    51  	expectErrorCode string
    52  	expectErrorInfo map[string]interface{}
    53  }{{
    54  	about: "success",
    55  	handler: func(w http.ResponseWriter, req *http.Request) {
    56  		httprequest.WriteJSON(w, http.StatusOK, "hello, world")
    57  	},
    58  	expectResponse: newString("hello, world"),
    59  }, {
    60  	about: "unauthorized status without discharge-required error",
    61  	handler: func(w http.ResponseWriter, req *http.Request) {
    62  		httprequest.WriteJSON(w, http.StatusUnauthorized, params.Error{
    63  			Message: "something",
    64  		})
    65  	},
    66  	expectError: `Get http://.*/: something`,
    67  }, {
    68  	about:         "non-JSON NotFound error response",
    69  	handler:       http.NotFound,
    70  	expectError:   `(?m)Get http://.*/: 404 page not found.*`,
    71  	expectErrorIs: errors.NotFound,
    72  }, {
    73  	about: "bad error response",
    74  	handler: func(w http.ResponseWriter, req *http.Request) {
    75  		type badResponse struct {
    76  			Message map[string]int
    77  		}
    78  		httprequest.WriteJSON(w, http.StatusUnauthorized, badResponse{
    79  			Message: make(map[string]int),
    80  		})
    81  	},
    82  	expectError: `Get http://.*/: incompatible error response: json: cannot unmarshal object into Go .+`,
    83  }, {
    84  	about: "bad charms error response",
    85  	handler: func(w http.ResponseWriter, req *http.Request) {
    86  		type badResponse struct {
    87  			Error    string         `json:"error"`
    88  			CharmURL map[string]int `json:"charm-url"`
    89  		}
    90  		httprequest.WriteJSON(w, http.StatusUnauthorized, badResponse{
    91  			Error:    "something",
    92  			CharmURL: make(map[string]int),
    93  		})
    94  	},
    95  	expectError: `Get http://.*/: incompatible error response: json: cannot unmarshal object into Go .+`,
    96  }, {
    97  	about: "no message in ErrorResponse",
    98  	handler: func(w http.ResponseWriter, req *http.Request) {
    99  		httprequest.WriteJSON(w, http.StatusUnauthorized, params.ErrorResult{
   100  			Error: &params.Error{},
   101  		})
   102  	},
   103  	expectError: `Get http://.*/: error response with no message`,
   104  }, {
   105  	about: "no message in Error",
   106  	handler: func(w http.ResponseWriter, req *http.Request) {
   107  		httprequest.WriteJSON(w, http.StatusUnauthorized, params.Error{})
   108  	},
   109  	expectError: `Get http://.*/: error response with no message`,
   110  }, {
   111  	about: "charms error response",
   112  	handler: func(w http.ResponseWriter, req *http.Request) {
   113  		httprequest.WriteJSON(w, http.StatusBadRequest, params.CharmsResponse{
   114  			Error:     "some error",
   115  			ErrorCode: params.CodeBadRequest,
   116  			ErrorInfo: params.DischargeRequiredErrorInfo{
   117  				MacaroonPath: "foo",
   118  			}.AsMap(),
   119  		})
   120  	},
   121  	expectError:     `.*some error$`,
   122  	expectErrorCode: params.CodeBadRequest,
   123  	expectErrorInfo: params.DischargeRequiredErrorInfo{
   124  		MacaroonPath: "foo",
   125  	}.AsMap(),
   126  }, {
   127  	about: "discharge-required response with no attached info",
   128  	handler: func(w http.ResponseWriter, req *http.Request) {
   129  		httprequest.WriteJSON(w, http.StatusUnauthorized, params.Error{
   130  			Message: "some error",
   131  			Code:    params.CodeDischargeRequired,
   132  		})
   133  	},
   134  	expectError:     `Get http://.*/: no error info found in discharge-required response error: some error`,
   135  	expectErrorCode: params.CodeDischargeRequired,
   136  }, {
   137  	about: "discharge-required response with no macaroon",
   138  	handler: func(w http.ResponseWriter, req *http.Request) {
   139  		httprequest.WriteJSON(w, http.StatusUnauthorized, params.Error{
   140  			Message: "some error",
   141  			Code:    params.CodeDischargeRequired,
   142  			Info: params.DischargeRequiredErrorInfo{
   143  				MacaroonPath: "/",
   144  			}.AsMap(),
   145  		})
   146  	},
   147  	expectError: `Get http://.*/: no macaroon found in discharge-required response`,
   148  }}
   149  
   150  func (s *httpSuite) TestHTTPClient(c *gc.C) {
   151  	var handler http.HandlerFunc
   152  	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
   153  		handler(w, req)
   154  	}))
   155  	defer srv.Close()
   156  	s.client.BaseURL = srv.URL
   157  	for i, test := range httpClientTests {
   158  		c.Logf("test %d: %s", i, test.about)
   159  		handler = test.handler
   160  		var resp interface{}
   161  		if test.expectResponse != nil {
   162  			resp = reflect.New(reflect.TypeOf(test.expectResponse).Elem()).Interface()
   163  		}
   164  		err := s.client.Get(context.Background(), "/", resp)
   165  		if test.expectError != "" {
   166  			c.Check(err, gc.ErrorMatches, test.expectError)
   167  			c.Check(params.ErrCode(err), gc.Equals, test.expectErrorCode)
   168  			if test.expectErrorIs != "" {
   169  				c.Check(errors.Cause(err), jc.ErrorIs, test.expectErrorIs)
   170  			}
   171  			if err, ok := errors.Cause(err).(*params.Error); ok {
   172  				c.Check(err.Info, jc.DeepEquals, test.expectErrorInfo)
   173  			} else if test.expectErrorInfo != nil {
   174  				c.Fatalf("no error info found in error")
   175  			}
   176  			continue
   177  		}
   178  		c.Check(err, gc.IsNil)
   179  		c.Check(resp, jc.DeepEquals, test.expectResponse)
   180  	}
   181  }
   182  
   183  func (s *httpSuite) TestControllerMachineAuthForHostedModel(c *gc.C) {
   184  	// Create a controller machine & hosted model.
   185  	const nonce = "gary"
   186  	m, password := s.Factory.MakeMachineReturningPassword(c, &factory.MachineParams{
   187  		Jobs:  []state.MachineJob{state.JobManageModel},
   188  		Nonce: nonce,
   189  	})
   190  	hostedState := s.Factory.MakeModel(c, nil)
   191  	defer hostedState.Close()
   192  
   193  	// Connect to the hosted model using the credentials of the
   194  	// controller machine.
   195  	apiInfo := s.APIInfo(c)
   196  	apiInfo.Tag = m.Tag()
   197  	apiInfo.Password = password
   198  	hostedModel, err := hostedState.Model()
   199  	c.Assert(err, jc.ErrorIsNil)
   200  	apiInfo.ModelTag = hostedModel.ModelTag()
   201  	apiInfo.Nonce = nonce
   202  	conn, err := api.Open(apiInfo, api.DialOpts{})
   203  	c.Assert(err, jc.ErrorIsNil)
   204  	httpClient, err := conn.HTTPClient()
   205  	c.Assert(err, jc.ErrorIsNil)
   206  
   207  	// Test with a dummy HTTP server returns the auth related headers used.
   208  	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
   209  		username, password, ok := req.BasicAuth()
   210  		if ok {
   211  			httprequest.WriteJSON(w, http.StatusOK, map[string]string{
   212  				"username": username,
   213  				"password": password,
   214  				"nonce":    req.Header.Get(params.MachineNonceHeader),
   215  			})
   216  		} else {
   217  			httprequest.WriteJSON(w, http.StatusUnauthorized, params.Error{
   218  				Message: "no auth header",
   219  			})
   220  		}
   221  	}))
   222  	defer srv.Close()
   223  	httpClient.BaseURL = srv.URL
   224  	var out map[string]string
   225  	c.Assert(httpClient.Get(context.Background(), "/", &out), jc.ErrorIsNil)
   226  	c.Assert(out, gc.DeepEquals, map[string]string{
   227  		"username": m.Tag().String(),
   228  		"password": password,
   229  		"nonce":    nonce,
   230  	})
   231  }
   232  
   233  func (s *httpSuite) TestAuthHTTPRequest(c *gc.C) {
   234  	apiInfo := &api.Info{}
   235  
   236  	req := s.authHTTPRequest(c, apiInfo)
   237  	_, _, ok := req.BasicAuth()
   238  	c.Assert(ok, jc.IsFalse)
   239  	c.Assert(req.Header, gc.HasLen, 2)
   240  	c.Assert(req.Header.Get(httpbakery.BakeryProtocolHeader), gc.Equals, "3")
   241  	c.Assert(req.Header.Get(params.JujuClientVersion), gc.Equals, version.Current.String())
   242  
   243  	apiInfo.Nonce = "foo"
   244  	req = s.authHTTPRequest(c, apiInfo)
   245  	_, _, ok = req.BasicAuth()
   246  	c.Assert(ok, jc.IsFalse)
   247  	c.Assert(req.Header.Get(params.MachineNonceHeader), gc.Equals, "foo")
   248  	c.Assert(req.Header.Get(httpbakery.BakeryProtocolHeader), gc.Equals, "3")
   249  
   250  	apiInfo.Tag = names.NewMachineTag("123")
   251  	apiInfo.Password = "password"
   252  	req = s.authHTTPRequest(c, apiInfo)
   253  	user, pass, ok := req.BasicAuth()
   254  	c.Assert(ok, jc.IsTrue)
   255  	c.Assert(user, gc.Equals, "machine-123")
   256  	c.Assert(pass, gc.Equals, "password")
   257  	c.Assert(req.Header.Get(params.MachineNonceHeader), gc.Equals, "foo")
   258  	c.Assert(req.Header.Get(httpbakery.BakeryProtocolHeader), gc.Equals, "3")
   259  
   260  	mac, err := apitesting.NewMacaroon("id")
   261  	c.Assert(err, jc.ErrorIsNil)
   262  	apiInfo.Macaroons = []macaroon.Slice{{mac}}
   263  	req = s.authHTTPRequest(c, apiInfo)
   264  	c.Assert(req.Header.Get(params.MachineNonceHeader), gc.Equals, "foo")
   265  	c.Assert(req.Header.Get(httpbakery.BakeryProtocolHeader), gc.Equals, "3")
   266  	macaroons := httpbakery.RequestMacaroons(req)
   267  	apitesting.MacaroonsEqual(c, macaroons, apiInfo.Macaroons)
   268  }
   269  
   270  func (s *httpSuite) authHTTPRequest(c *gc.C, info *api.Info) *http.Request {
   271  	req, err := http.NewRequest(http.MethodGet, "/", nil)
   272  	c.Assert(err, jc.ErrorIsNil)
   273  	err = api.AuthHTTPRequest(req, info)
   274  	c.Assert(err, jc.ErrorIsNil)
   275  	return req
   276  }
   277  
   278  // Note: the fact that the code works against the actual API server is
   279  // well tested by some of the other API tests.
   280  // This suite focuses on less reachable paths by changing
   281  // the BaseURL of the httprequest.Client so that
   282  // we can use our own custom servers.
   283  
   284  func newString(s string) *string {
   285  	return &s
   286  }