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: ¶ms.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 }