github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/apiserver/common/crossmodel/bakery_test.go (about) 1 // Copyright 2024 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 package crossmodel_test 5 6 import ( 7 "context" 8 "fmt" 9 "io" 10 "net/http" 11 "sort" 12 "strings" 13 14 "github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery" 15 "github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery/checkers" 16 "github.com/juju/testing" 17 jc "github.com/juju/testing/checkers" 18 "go.uber.org/mock/gomock" 19 gc "gopkg.in/check.v1" 20 "gopkg.in/macaroon.v2" 21 22 apitesting "github.com/juju/juju/api/testing" 23 "github.com/juju/juju/apiserver/common/crossmodel" 24 "github.com/juju/juju/apiserver/common/crossmodel/mocks" 25 coretesting "github.com/juju/juju/testing" 26 ) 27 28 type bakerySuite struct { 29 testing.IsolationSuite 30 31 mockRoundTripper *mocks.MockRoundTripper 32 mockExpirableStorageBakery *mocks.MockExpirableStorageBakery 33 } 34 35 var _ = gc.Suite(&bakerySuite{}) 36 37 func (s *bakerySuite) getLocalOfferBakery(c *gc.C) (*crossmodel.OfferBakery, *gomock.Controller) { 38 ctrl := gomock.NewController(c) 39 40 mockRoundTripper := mocks.NewMockRoundTripper(ctrl) 41 s.PatchValue(&crossmodel.DefaultTransport, mockRoundTripper) 42 mockBakeryConfig := mocks.NewMockBakeryConfig(ctrl) 43 mockExpirableStorage := mocks.NewMockExpirableStorage(ctrl) 44 mockFirstPartyCaveatChecker := mocks.NewMockFirstPartyCaveatChecker(ctrl) 45 s.mockExpirableStorageBakery = mocks.NewMockExpirableStorageBakery(ctrl) 46 47 key, err := bakery.GenerateKey() 48 c.Assert(err, gc.IsNil) 49 mockBakeryConfig.EXPECT().GetOffersThirdPartyKey().Return(key, nil) 50 mockFirstPartyCaveatChecker.EXPECT().Namespace().Return(nil) 51 52 b, err := crossmodel.NewLocalOfferBakery("", mockBakeryConfig, mockExpirableStorage, mockFirstPartyCaveatChecker) 53 c.Assert(err, gc.IsNil) 54 c.Assert(b, gc.NotNil) 55 url, err := b.RefreshDischargeURL("https://example.com/offeraccess") 56 c.Assert(err, jc.ErrorIsNil) 57 c.Assert(url, gc.Equals, "https://example.com/offeraccess") 58 return b, ctrl 59 } 60 61 func (s *bakerySuite) getJaaSOfferBakery(c *gc.C) (*crossmodel.JaaSOfferBakery, *gomock.Controller) { 62 ctrl := gomock.NewController(c) 63 64 s.mockRoundTripper = mocks.NewMockRoundTripper(ctrl) 65 s.PatchValue(&crossmodel.DefaultTransport, s.mockRoundTripper) 66 mockBakeryConfig := mocks.NewMockBakeryConfig(ctrl) 67 mockExpirableStorage := mocks.NewMockExpirableStorage(ctrl) 68 mockFirstPartyCaveatChecker := mocks.NewMockFirstPartyCaveatChecker(ctrl) 69 70 key, err := bakery.GenerateKey() 71 c.Assert(err, gc.IsNil) 72 mockBakeryConfig.EXPECT().GetExternalUsersThirdPartyKey().Return(key, nil).AnyTimes() 73 mockFirstPartyCaveatChecker.EXPECT().Namespace().Return(nil).AnyTimes() 74 s.mockRoundTripper.EXPECT().RoundTrip(gomock.Any()).DoAndReturn( 75 func(req *http.Request) (*http.Response, error) { 76 req.Header.Set("Content-Type", "application/json") 77 c.Assert(req.URL.String(), gc.Equals, `https://example.com/macaroons/discharge/info`) 78 resp := &http.Response{ 79 Request: req, 80 StatusCode: http.StatusOK, 81 Body: io.NopCloser( 82 strings.NewReader( 83 `{"PublicKey": "AhIuwQfV71m2G+DhE/YNT1jIbSvp6jWgivTf06+tLBU=", "Version": 3}`, 84 ), 85 ), 86 } 87 resp.Header = req.Header 88 return resp, nil 89 }, 90 ) 91 92 b, err := crossmodel.NewJaaSOfferBakery( 93 "https://example.com/.well-known/jwks.json", "", 94 mockBakeryConfig, mockExpirableStorage, mockFirstPartyCaveatChecker, 95 ) 96 c.Assert(err, gc.IsNil) 97 c.Assert(b, gc.NotNil) 98 return b, ctrl 99 } 100 101 func (s *bakerySuite) TestRefreshDischargeURL(c *gc.C) { 102 offerBakery, ctrl := s.getLocalOfferBakery(c) 103 defer ctrl.Finish() 104 105 result, err := offerBakery.RefreshDischargeURL("https://example-1.com/offeraccess") 106 c.Assert(err, gc.IsNil) 107 c.Assert(result, gc.Equals, "https://example-1.com/offeraccess") 108 } 109 110 func (s *bakerySuite) TestRefreshDischargeURLJaaS(c *gc.C) { 111 offerBakery, ctrl := s.getJaaSOfferBakery(c) 112 defer ctrl.Finish() 113 114 s.mockRoundTripper.EXPECT().RoundTrip(gomock.Any()).DoAndReturn( 115 func(req *http.Request) (*http.Response, error) { 116 req.Header.Set("Content-Type", "application/json") 117 c.Assert(req.URL.String(), gc.Equals, `https://example-1.com/macaroons/discharge/info`) 118 resp := &http.Response{ 119 Request: req, 120 StatusCode: http.StatusOK, 121 Body: io.NopCloser( 122 strings.NewReader( 123 `{"PublicKey": "AhIuwQfV71m2G+DhE/YNT1jIbSvp6jWgivTf06+tLBU=", "Version": 3}`, 124 ), 125 ), 126 } 127 resp.Header = req.Header 128 return resp, nil 129 }, 130 ) 131 132 result, err := offerBakery.RefreshDischargeURL("https://example-1.com/.well-known/jwks.json") 133 c.Assert(err, gc.IsNil) 134 c.Assert(result, gc.Equals, "https://example-1.com/macaroons") 135 } 136 137 func (s *bakerySuite) TestGetConsumeOfferCaveats(c *gc.C) { 138 offerBakery, ctrl := s.getLocalOfferBakery(c) 139 defer ctrl.Finish() 140 141 caveats := offerBakery.GetConsumeOfferCaveats( 142 "offer-uuid", "model-uuid", "mary", 143 ) 144 c.Assert(caveats, gc.HasLen, 4) 145 c.Assert(strings.HasPrefix(caveats[0].Condition, "time-before"), jc.IsTrue) 146 c.Assert(caveats[1], jc.DeepEquals, checkers.Caveat{ 147 Condition: "declared source-model-uuid model-uuid", Namespace: "std", 148 }) 149 c.Assert(caveats[2], jc.DeepEquals, checkers.Caveat{ 150 Condition: "declared username mary", Namespace: "std", 151 }) 152 c.Assert(caveats[3], jc.DeepEquals, checkers.Caveat{ 153 Condition: "declared offer-uuid offer-uuid", Namespace: "std", 154 }) 155 } 156 157 func (s *bakerySuite) TestGetConsumeOfferCaveatsJaaS(c *gc.C) { 158 offerBakery, ctrl := s.getJaaSOfferBakery(c) 159 defer ctrl.Finish() 160 161 caveats := offerBakery.GetConsumeOfferCaveats( 162 "offer-uuid", "model-uuid", "mary", 163 ) 164 c.Assert(caveats, gc.HasLen, 3) 165 c.Assert(strings.HasPrefix(caveats[0].Condition, "time-before"), jc.IsTrue) 166 c.Assert(caveats[1], jc.DeepEquals, checkers.Caveat{ 167 Condition: "declared source-model-uuid model-uuid", Namespace: "std", 168 }) 169 c.Assert(caveats[2], jc.DeepEquals, checkers.Caveat{ 170 Condition: "declared username mary", Namespace: "std", 171 }) 172 } 173 174 func (s *bakerySuite) TestInferDeclaredFromMacaroon(c *gc.C) { 175 offerBakery, ctrl := s.getLocalOfferBakery(c) 176 defer ctrl.Finish() 177 178 mac := apitesting.MustNewMacaroon("test") 179 declared := offerBakery.InferDeclaredFromMacaroon( 180 macaroon.Slice{mac}, map[string]string{"relation-key": "mediawiki:db mysql:server"}, 181 ) 182 c.Assert(declared, gc.DeepEquals, map[string]string{}) 183 } 184 185 func (s *bakerySuite) TestInferDeclaredFromMacaroonJaaS(c *gc.C) { 186 offerBakery, ctrl := s.getJaaSOfferBakery(c) 187 defer ctrl.Finish() 188 189 mac := apitesting.MustNewMacaroon("test") 190 declared := offerBakery.InferDeclaredFromMacaroon( 191 macaroon.Slice{mac}, map[string]string{"relation-key": "mediawiki:db mysql:server"}, 192 ) 193 c.Assert(declared, gc.DeepEquals, map[string]string{"relation-key": "mediawiki:db mysql:server"}) 194 } 195 196 func (s *bakerySuite) TestCreateDischargeMacaroon(c *gc.C) { 197 offerBakery, ctrl := s.getLocalOfferBakery(c) 198 defer ctrl.Finish() 199 200 offerBakery.SetBakery(s.mockExpirableStorageBakery) 201 202 s.mockExpirableStorageBakery.EXPECT().ExpireStorageAfter(gomock.Any()).Return(s.mockExpirableStorageBakery, nil) 203 s.mockExpirableStorageBakery.EXPECT().NewMacaroon(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( 204 func(ctx context.Context, version bakery.Version, caveats []checkers.Caveat, ops ...bakery.Op) (*bakery.Macaroon, error) { 205 sort.Slice(caveats, func(i, j int) bool { 206 return caveats[i].Condition < caveats[j].Condition 207 }) 208 c.Assert(caveats, gc.HasLen, 2) 209 cavCondition := fmt.Sprintf(` 210 need-declared offer-uuid,relation-key,source-model-uuid,username,username has-offer-permission source-model-uuid: %s 211 username: mary 212 offer-uuid: mysql-uuid 213 relation-key: mediawiki:db mysql:server 214 permission: consume 215 `[1:], coretesting.ModelTag.Id()) 216 c.Assert(caveats[0], jc.DeepEquals, checkers.Caveat{ 217 Condition: cavCondition, 218 Location: "https://example.com/offeraccess", 219 }) 220 c.Assert(strings.HasPrefix(caveats[1].Condition, "time-before"), jc.IsTrue) 221 c.Assert(ops, gc.HasLen, 1) 222 c.Assert(ops[0], jc.DeepEquals, bakery.Op{Action: "consume", Entity: "mysql-uuid"}) 223 return bakery.NewLegacyMacaroon(apitesting.MustNewMacaroon("test")) 224 }, 225 ) 226 _, err := offerBakery.CreateDischargeMacaroon( 227 context.Background(), "https://example.com/offeraccess", "mary", 228 map[string]string{ 229 "relation-key": "mediawiki:db mysql:server", 230 "username": "mary", 231 "offer-uuid": "mysql-uuid", 232 "source-model-uuid": coretesting.ModelTag.Id(), 233 }, 234 map[string]string{ 235 "relation-key": "mediawiki:db mysql:server", 236 "username": "mary", 237 "source-model-uuid": coretesting.ModelTag.Id(), 238 }, 239 bakery.Op{Action: "consume", Entity: "mysql-uuid"}, 240 bakery.LatestVersion, 241 ) 242 c.Assert(err, gc.IsNil) 243 } 244 245 func (s *bakerySuite) TestCreateDischargeMacaroonJaaS(c *gc.C) { 246 offerBakery, ctrl := s.getJaaSOfferBakery(c) 247 defer ctrl.Finish() 248 249 offerBakery.SetBakery(s.mockExpirableStorageBakery) 250 251 s.mockExpirableStorageBakery.EXPECT().ExpireStorageAfter(gomock.Any()).Return(s.mockExpirableStorageBakery, nil) 252 s.mockExpirableStorageBakery.EXPECT().NewMacaroon(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( 253 func(ctx context.Context, version bakery.Version, caveats []checkers.Caveat, ops ...bakery.Op) (*bakery.Macaroon, error) { 254 sort.Slice(caveats, func(i, j int) bool { 255 return caveats[i].Condition < caveats[j].Condition 256 }) 257 c.Assert(caveats, gc.HasLen, 5) 258 c.Assert(caveats[0], jc.DeepEquals, checkers.Caveat{ 259 Condition: "declared relation-key mediawiki:db mysql:server", Namespace: "std", 260 }) 261 c.Assert(caveats[1], jc.DeepEquals, checkers.Caveat{ 262 Condition: "declared source-model-uuid " + coretesting.ModelTag.Id(), Namespace: "std", 263 }) 264 c.Assert(caveats[2], jc.DeepEquals, checkers.Caveat{ 265 Condition: "declared username mary", Namespace: "std", 266 }) 267 c.Assert(caveats[3], jc.DeepEquals, checkers.Caveat{ 268 Location: "https://example.com/macaroons", Condition: "is-consumer user-mary mysql-uuid", 269 }) 270 c.Assert(strings.HasPrefix(caveats[4].Condition, "time-before"), jc.IsTrue) 271 return bakery.NewLegacyMacaroon(apitesting.MustNewMacaroon("test")) 272 }, 273 ) 274 _, err := offerBakery.CreateDischargeMacaroon( 275 context.Background(), "https://example.com/macaroons", "mary", 276 map[string]string{ 277 "relation-key": "mediawiki:db mysql:server", 278 "username": "mary", 279 "offer-uuid": "mysql-uuid", 280 "source-model-uuid": coretesting.ModelTag.Id(), 281 }, 282 map[string]string{ 283 "relation-key": "mediawiki:db mysql:server", 284 "username": "mary", 285 "source-model-uuid": coretesting.ModelTag.Id(), 286 }, 287 bakery.Op{Action: "consume", Entity: "mysql-uuid"}, 288 bakery.LatestVersion, 289 ) 290 c.Assert(err, gc.IsNil) 291 }