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  }