go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config_service/rpc/get_project_configs_test.go (about)

     1  // Copyright 2023 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package rpc
    16  
    17  import (
    18  	"crypto/sha256"
    19  	"encoding/hex"
    20  	"strings"
    21  	"testing"
    22  
    23  	"github.com/golang/mock/gomock"
    24  	"google.golang.org/genproto/protobuf/field_mask"
    25  	"google.golang.org/grpc/codes"
    26  	"google.golang.org/protobuf/encoding/prototext"
    27  	"google.golang.org/protobuf/proto"
    28  
    29  	"go.chromium.org/luci/auth/identity"
    30  	"go.chromium.org/luci/common/errors"
    31  	cfgcommonpb "go.chromium.org/luci/common/proto/config"
    32  	"go.chromium.org/luci/config"
    33  	"go.chromium.org/luci/gae/service/datastore"
    34  	"go.chromium.org/luci/server/auth"
    35  	"go.chromium.org/luci/server/auth/authtest"
    36  
    37  	"go.chromium.org/luci/config_service/internal/clients"
    38  	"go.chromium.org/luci/config_service/internal/common"
    39  	"go.chromium.org/luci/config_service/internal/model"
    40  	pb "go.chromium.org/luci/config_service/proto"
    41  	"go.chromium.org/luci/config_service/testutil"
    42  
    43  	. "github.com/smartystreets/goconvey/convey"
    44  	. "go.chromium.org/luci/common/testing/assertions"
    45  )
    46  
    47  func TestGetProjectConfigs(t *testing.T) {
    48  	t.Parallel()
    49  
    50  	Convey("GetProjectConfigs", t, func() {
    51  		ctx := testutil.SetupContext()
    52  		srv := &Configs{}
    53  
    54  		userID := identity.Identity("user:user@example.com")
    55  		fakeAuthDB := authtest.NewFakeDB()
    56  		testutil.InjectSelfConfigs(ctx, map[string]proto.Message{
    57  			common.ACLRegistryFilePath: &cfgcommonpb.AclCfg{
    58  				ProjectAccessGroup: "project-access-group",
    59  			},
    60  		})
    61  		fakeAuthDB.AddMocks(
    62  			authtest.MockMembership(userID, "project-access-group"),
    63  		)
    64  		ctx = auth.WithState(ctx, &authtest.FakeState{
    65  			Identity: userID,
    66  			FakeDB:   fakeAuthDB,
    67  		})
    68  
    69  		// Inject "services/myservice"
    70  		testutil.InjectConfigSet(ctx, config.MustServiceSet("myservice"), nil)
    71  
    72  		// Inject "projects/project1" with a small "config.cfg" file and "other1.cfg" file.
    73  		configPb := &cfgcommonpb.ProjectCfg{Name: "config.cfg"}
    74  		configPbBytes, err := prototext.Marshal(configPb)
    75  		So(err, ShouldBeNil)
    76  		configPbSha := sha256.Sum256(configPbBytes)
    77  		configPbShaStr := hex.EncodeToString(configPbSha[:])
    78  		testutil.InjectConfigSet(ctx, config.MustProjectSet("project1"), map[string]proto.Message{
    79  			"config.cfg": configPb,
    80  			"other1.cfg": &cfgcommonpb.ProjectCfg{Name: "other1.cfg"},
    81  		})
    82  
    83  		// Inject "projects/project2" with a large "config.cfg" file and "other2.cfg" file.
    84  		testutil.InjectConfigSet(ctx, config.MustProjectSet("project2"), map[string]proto.Message{
    85  			"other2.cfg": &cfgcommonpb.ProjectCfg{Name: "other2.cfg"},
    86  		})
    87  		So(datastore.Put(ctx, &model.File{
    88  			Path:          "config.cfg",
    89  			Revision:      datastore.MakeKey(ctx, model.ConfigSetKind, "projects/project2", model.RevisionKind, "1"),
    90  			ContentSHA256: "configsha256",
    91  			GcsURI:        "gs://bucket/configsha256",
    92  			Size:          1000,
    93  		}), ShouldBeNil)
    94  		ctl := gomock.NewController(t)
    95  		defer ctl.Finish()
    96  		mockGsClient := clients.NewMockGsClient(ctl)
    97  		ctx = clients.WithGsClient(ctx, mockGsClient)
    98  
    99  		Convey("invalid path", func() {
   100  			res, err := srv.GetProjectConfigs(ctx, &pb.GetProjectConfigsRequest{})
   101  			So(res, ShouldBeNil)
   102  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument, `invalid path - "": not specified`)
   103  
   104  			res, err = srv.GetProjectConfigs(ctx, &pb.GetProjectConfigsRequest{Path: "/file"})
   105  			So(res, ShouldBeNil)
   106  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument, `invalid path - "/file": must not be absolute`)
   107  
   108  			res, err = srv.GetProjectConfigs(ctx, &pb.GetProjectConfigsRequest{Path: "./file"})
   109  			So(res, ShouldBeNil)
   110  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument, `invalid path - "./file": should not start with './' or '../'`)
   111  		})
   112  
   113  		Convey("invalid mask", func() {
   114  			res, err := srv.GetProjectConfigs(ctx, &pb.GetProjectConfigsRequest{
   115  				Path: "file",
   116  				Fields: &field_mask.FieldMask{
   117  					Paths: []string{"random"},
   118  				},
   119  			})
   120  			So(res, ShouldBeNil)
   121  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument, `invalid fields mask: field "random" does not exist in message Config`)
   122  		})
   123  
   124  		Convey("no access to matched files", func() {
   125  			ctx = auth.WithState(ctx, &authtest.FakeState{
   126  				Identity: identity.Identity("user:random@example.com"),
   127  				FakeDB:   fakeAuthDB,
   128  			})
   129  			res, err := srv.GetProjectConfigs(ctx, &pb.GetProjectConfigsRequest{
   130  				Path: "config.cfg",
   131  			})
   132  			So(err, ShouldBeNil)
   133  			So(res, ShouldResembleProto, &pb.GetProjectConfigsResponse{})
   134  		})
   135  
   136  		Convey("no matched files", func() {
   137  			res, err := srv.GetProjectConfigs(ctx, &pb.GetProjectConfigsRequest{
   138  				Path: "non_exist.cfg",
   139  			})
   140  			So(err, ShouldBeNil)
   141  			So(res, ShouldResembleProto, &pb.GetProjectConfigsResponse{})
   142  		})
   143  
   144  		Convey("found", func() {
   145  			mockGsClient.EXPECT().SignedURL(
   146  				gomock.Eq("bucket"),
   147  				gomock.Eq("configsha256"),
   148  				gomock.Any(),
   149  			).Return("signed_url", nil)
   150  
   151  			res, err := srv.GetProjectConfigs(ctx, &pb.GetProjectConfigsRequest{
   152  				Path: "config.cfg",
   153  			})
   154  			So(err, ShouldBeNil)
   155  			So(res, ShouldResembleProto, &pb.GetProjectConfigsResponse{
   156  				Configs: []*pb.Config{
   157  					{
   158  						ConfigSet: "projects/project1",
   159  						Path:      "config.cfg",
   160  						Content: &pb.Config_RawContent{
   161  							RawContent: configPbBytes,
   162  						},
   163  						ContentSha256: configPbShaStr,
   164  						Revision:      "1",
   165  						Size:          int64(len(configPbBytes)),
   166  					},
   167  					{
   168  						ConfigSet: "projects/project2",
   169  						Path:      "config.cfg",
   170  						Content: &pb.Config_SignedUrl{
   171  							SignedUrl: "signed_url",
   172  						},
   173  						ContentSha256: "configsha256",
   174  						Revision:      "1",
   175  						Size:          1000,
   176  					},
   177  				},
   178  			})
   179  		})
   180  
   181  		Convey(" config size > maxRawContentSize", func() {
   182  			fooPb := &cfgcommonpb.ProjectCfg{Name: strings.Repeat("0123456789", maxRawContentSize/10)}
   183  			fooPbBytes, err := prototext.Marshal(fooPb)
   184  			So(err, ShouldBeNil)
   185  			fooPbSha := sha256.Sum256(fooPbBytes)
   186  			fooPbShaStr := hex.EncodeToString(fooPbSha[:])
   187  
   188  			testutil.InjectConfigSet(ctx, config.MustProjectSet("foo"), map[string]proto.Message{
   189  				"foo.cfg": fooPb,
   190  			})
   191  			mockGsClient.EXPECT().SignedURL(
   192  				gomock.Eq(testutil.TestGsBucket),
   193  				gomock.Eq("foo.cfg"),
   194  				gomock.Any(),
   195  			).Return("signed_url", nil)
   196  
   197  			res, err := srv.GetProjectConfigs(ctx, &pb.GetProjectConfigsRequest{
   198  				Path: "foo.cfg",
   199  			})
   200  			So(err, ShouldBeNil)
   201  			So(res, ShouldResembleProto, &pb.GetProjectConfigsResponse{
   202  				Configs: []*pb.Config{
   203  					{
   204  						ConfigSet: "projects/foo",
   205  						Path:      "foo.cfg",
   206  						Content: &pb.Config_SignedUrl{
   207  							SignedUrl: "signed_url",
   208  						},
   209  						ContentSha256: fooPbShaStr,
   210  						Revision:      "1",
   211  						Size:          int64(len(fooPbBytes)),
   212  					},
   213  				},
   214  			})
   215  		})
   216  
   217  		Convey("total size > maxProjConfigsResSize", func() {
   218  			originalLimit := maxProjConfigsResSize
   219  			// Make the limit to 1 byte to avoid taking too much memory to test this
   220  			// use case.
   221  			maxProjConfigsResSize = 1
   222  			defer func() { maxProjConfigsResSize = originalLimit }()
   223  
   224  			mockGsClient.EXPECT().SignedURL(
   225  				gomock.Eq(testutil.TestGsBucket),
   226  				gomock.Eq("config.cfg"),
   227  				gomock.Any(),
   228  			).Return("signed_url1", nil)
   229  			mockGsClient.EXPECT().SignedURL(
   230  				gomock.Eq("bucket"),
   231  				gomock.Eq("configsha256"),
   232  				gomock.Any(),
   233  			).Return("signed_url2", nil)
   234  
   235  			res, err := srv.GetProjectConfigs(ctx, &pb.GetProjectConfigsRequest{
   236  				Path: "config.cfg",
   237  			})
   238  			So(err, ShouldBeNil)
   239  			So(res, ShouldResembleProto, &pb.GetProjectConfigsResponse{
   240  				Configs: []*pb.Config{
   241  					{
   242  						ConfigSet: "projects/project1",
   243  						Path:      "config.cfg",
   244  						Content: &pb.Config_SignedUrl{
   245  							SignedUrl: "signed_url1",
   246  						},
   247  						ContentSha256: configPbShaStr,
   248  						Revision:      "1",
   249  						Size:          int64(len(configPbBytes)),
   250  					},
   251  					{
   252  						ConfigSet: "projects/project2",
   253  						Path:      "config.cfg",
   254  						Content: &pb.Config_SignedUrl{
   255  							SignedUrl: "signed_url2",
   256  						},
   257  						ContentSha256: "configsha256",
   258  						Revision:      "1",
   259  						Size:          1000,
   260  					},
   261  				},
   262  			})
   263  		})
   264  
   265  		Convey("mask", func() {
   266  			res, err := srv.GetProjectConfigs(ctx, &pb.GetProjectConfigsRequest{
   267  				Path: "other1.cfg",
   268  				Fields: &field_mask.FieldMask{
   269  					Paths: []string{"config_set", "path"},
   270  				},
   271  			})
   272  
   273  			So(err, ShouldBeNil)
   274  			So(res, ShouldResembleProto, &pb.GetProjectConfigsResponse{
   275  				Configs: []*pb.Config{
   276  					{
   277  						ConfigSet: "projects/project1",
   278  						Path:      "other1.cfg",
   279  					},
   280  				},
   281  			})
   282  		})
   283  
   284  		Convey("GCS error on signed url", func() {
   285  			mockGsClient.EXPECT().SignedURL(
   286  				gomock.Eq("bucket"),
   287  				gomock.Eq("configsha256"),
   288  				gomock.Any(),
   289  			).Return("", errors.New("GCS internal error"))
   290  
   291  			res, err := srv.GetProjectConfigs(ctx, &pb.GetProjectConfigsRequest{
   292  				Path: "config.cfg",
   293  			})
   294  			So(res, ShouldBeNil)
   295  			So(err, ShouldHaveGRPCStatus, codes.Internal, "error while generating the config signed url")
   296  		})
   297  	})
   298  }