go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config_service/rpc/validate_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  	"context"
    19  	"crypto/sha256"
    20  	"errors"
    21  	"fmt"
    22  	"testing"
    23  
    24  	"google.golang.org/grpc/codes"
    25  	"google.golang.org/grpc/status"
    26  	"google.golang.org/protobuf/proto"
    27  
    28  	"go.chromium.org/luci/auth/identity"
    29  	"go.chromium.org/luci/common/gcloud/gs"
    30  	cfgcommonpb "go.chromium.org/luci/common/proto/config"
    31  	"go.chromium.org/luci/config"
    32  	"go.chromium.org/luci/gae/service/datastore"
    33  	"go.chromium.org/luci/server/auth"
    34  	"go.chromium.org/luci/server/auth/authtest"
    35  
    36  	"go.chromium.org/luci/config_service/internal/common"
    37  	"go.chromium.org/luci/config_service/internal/model"
    38  	"go.chromium.org/luci/config_service/internal/validation"
    39  	configpb "go.chromium.org/luci/config_service/proto"
    40  	"go.chromium.org/luci/config_service/testutil"
    41  
    42  	. "github.com/smartystreets/goconvey/convey"
    43  	. "go.chromium.org/luci/common/testing/assertions"
    44  )
    45  
    46  type mockValidator struct {
    47  	examineResult  *validation.ExamineResult
    48  	examineErr     error
    49  	validateResult *cfgcommonpb.ValidationResult
    50  	validateErr    error
    51  
    52  	recordedExamineFiles  []validation.File
    53  	recordedValidateFiles []validation.File
    54  }
    55  
    56  func (mv *mockValidator) Examine(ctx context.Context, cs config.Set, files []validation.File) (*validation.ExamineResult, error) {
    57  	mv.recordedExamineFiles = files
    58  	if mv.examineErr != nil {
    59  		return nil, mv.examineErr
    60  	}
    61  	return mv.examineResult, nil
    62  
    63  }
    64  
    65  func (mv *mockValidator) Validate(ctx context.Context, cs config.Set, files []validation.File) (*cfgcommonpb.ValidationResult, error) {
    66  	mv.recordedValidateFiles = files
    67  	if mv.validateErr != nil {
    68  		return nil, mv.validateErr
    69  	}
    70  	return mv.validateResult, nil
    71  
    72  }
    73  
    74  func TestValidate(t *testing.T) {
    75  	t.Parallel()
    76  
    77  	Convey("Validate", t, func() {
    78  		ctx := testutil.SetupContext()
    79  		fakeAuthDB := authtest.NewFakeDB()
    80  		requester := identity.Identity(fmt.Sprintf("%s:requester@example.com", identity.User))
    81  		testutil.InjectSelfConfigs(ctx, map[string]proto.Message{
    82  			common.ACLRegistryFilePath: &cfgcommonpb.AclCfg{
    83  				ProjectAccessGroup:     "access-group",
    84  				ProjectValidationGroup: "validate-group",
    85  			},
    86  		})
    87  		fakeAuthDB.AddMocks(
    88  			authtest.MockMembership(requester, "access-group"),
    89  			authtest.MockMembership(requester, "validate-group"),
    90  		)
    91  		ctx = auth.WithState(ctx, &authtest.FakeState{
    92  			Identity: requester,
    93  			FakeDB:   fakeAuthDB,
    94  		})
    95  
    96  		mv := &mockValidator{}
    97  		c := &Configs{
    98  			Validator:          mv,
    99  			GSValidationBucket: "test-bucket",
   100  		}
   101  		cs := config.MustProjectSet("example-project")
   102  		So(datastore.Put(ctx, &model.ConfigSet{ID: cs}), ShouldBeNil)
   103  		const filePath = "sub/foo.cfg"
   104  		fileSHA256 := fmt.Sprintf("%x", sha256.Sum256([]byte("some content")))
   105  		validateRequest := &configpb.ValidateConfigsRequest{
   106  			ConfigSet: string(cs),
   107  			FileHashes: []*configpb.ValidateConfigsRequest_FileHash{
   108  				{Path: filePath, Sha256: fileSHA256},
   109  			},
   110  		}
   111  
   112  		Convey("Invalid input", func() {
   113  			req := proto.Clone(validateRequest).(*configpb.ValidateConfigsRequest)
   114  			Convey("Empty config set", func() {
   115  				req.ConfigSet = ""
   116  				res, err := c.ValidateConfigs(ctx, req)
   117  				So(res, ShouldBeNil)
   118  				So(err, ShouldHaveGRPCStatus, codes.InvalidArgument, "config set is required")
   119  			})
   120  			Convey("Invalid config set", func() {
   121  				req.ConfigSet = "bad bad"
   122  				res, err := c.ValidateConfigs(ctx, req)
   123  				So(res, ShouldBeNil)
   124  				So(err, ShouldHaveGRPCStatus, codes.InvalidArgument, "invalid config set")
   125  			})
   126  			Convey("Empty file hashes", func() {
   127  				req.FileHashes = nil
   128  				res, err := c.ValidateConfigs(ctx, req)
   129  				So(res, ShouldBeNil)
   130  				So(err, ShouldHaveGRPCStatus, codes.InvalidArgument, "must provide non-empty file_hashes")
   131  			})
   132  			Convey("Empty path", func() {
   133  				req.FileHashes[0].Path = ""
   134  				res, err := c.ValidateConfigs(ctx, req)
   135  				So(res, ShouldBeNil)
   136  				So(err, ShouldHaveGRPCStatus, codes.InvalidArgument, "file_hash[0]: path is empty")
   137  			})
   138  			Convey("Absolute path", func() {
   139  				req.FileHashes[0].Path = "/home/foo.cfg"
   140  				res, err := c.ValidateConfigs(ctx, req)
   141  				So(res, ShouldBeNil)
   142  				So(err, ShouldHaveGRPCStatus, codes.InvalidArgument, "must not be absolute")
   143  			})
   144  			for _, invalidSeg := range []string{".", ".."} {
   145  				Convey(fmt.Sprintf("Path contain %q", invalidSeg), func() {
   146  					req.FileHashes[0].Path = fmt.Sprintf("sub/%s/a.cfg", invalidSeg)
   147  					res, err := c.ValidateConfigs(ctx, req)
   148  					So(res, ShouldBeNil)
   149  					So(err, ShouldHaveGRPCStatus, codes.InvalidArgument, "must not contain '.' or '..' components")
   150  				})
   151  			}
   152  
   153  			Convey("Empty hash", func() {
   154  				req.FileHashes[0].Sha256 = ""
   155  				res, err := c.ValidateConfigs(ctx, req)
   156  				So(res, ShouldBeNil)
   157  				So(err, ShouldHaveGRPCStatus, codes.InvalidArgument, "file_hash[0]: sha256 is empty")
   158  			})
   159  			Convey("Invalid hash", func() {
   160  				req.FileHashes[0].Sha256 = "x.y.z"
   161  				res, err := c.ValidateConfigs(ctx, req)
   162  				So(res, ShouldBeNil)
   163  				So(err, ShouldHaveGRPCStatus, codes.InvalidArgument, "invalid sha256 hash")
   164  			})
   165  		})
   166  
   167  		Convey("ACL Check", func() {
   168  			Convey("Disallow anonymous", func() {
   169  				ctx = auth.WithState(ctx, &authtest.FakeState{
   170  					Identity: identity.AnonymousIdentity,
   171  					FakeDB:   fakeAuthDB,
   172  				})
   173  				res, err := c.ValidateConfigs(ctx, validateRequest)
   174  				So(res, ShouldBeNil)
   175  				So(err, ShouldHaveGRPCStatus, codes.PermissionDenied, "user must be authenticated to validate config")
   176  			})
   177  
   178  			Convey("Permission Denied", func() {
   179  				ctx = auth.WithState(ctx, &authtest.FakeState{
   180  					Identity: identity.Identity(fmt.Sprintf("%s:another-requester@example.com", identity.User)),
   181  					FakeDB:   fakeAuthDB,
   182  				})
   183  				res, err := c.ValidateConfigs(ctx, validateRequest)
   184  				So(res, ShouldBeNil)
   185  				So(err, ShouldHaveGRPCStatus, codes.PermissionDenied, "\"user:another-requester@example.com\" does not have permission to validate config set")
   186  			})
   187  		})
   188  
   189  		Convey("Unknown config Set", func() {
   190  			So(datastore.Delete(ctx, &model.ConfigSet{ID: cs}), ShouldBeNil)
   191  			res, err := c.ValidateConfigs(ctx, validateRequest)
   192  			So(err, ShouldBeNil)
   193  			So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{
   194  				Messages: []*cfgcommonpb.ValidationResult_Message{
   195  					{
   196  						Path:     ".",
   197  						Severity: cfgcommonpb.ValidationResult_WARNING,
   198  						Text:     "The config set is not registered, skipping validation",
   199  					},
   200  				},
   201  			})
   202  		})
   203  
   204  		Convey("Successful validation", func() {
   205  			vr := &cfgcommonpb.ValidationResult{
   206  				Messages: []*cfgcommonpb.ValidationResult_Message{
   207  					{
   208  						Path:     filePath,
   209  						Severity: cfgcommonpb.ValidationResult_ERROR,
   210  						Text:     "something is wrong",
   211  					},
   212  				},
   213  			}
   214  			mv.examineResult = &validation.ExamineResult{} //passed
   215  			So(mv.examineResult.Passed(), ShouldBeTrue)
   216  			mv.validateResult = vr
   217  			res, err := c.ValidateConfigs(ctx, validateRequest)
   218  			So(err, ShouldBeNil)
   219  			So(res, ShouldResembleProto, vr)
   220  			expectedValidationFiles := []validation.File{
   221  				validationFile{
   222  					path:   filePath,
   223  					gsPath: gs.MakePath("test-bucket", "validation", "users", "b8a0858b", "configs", "sha256", fileSHA256),
   224  				},
   225  			}
   226  			So(mv.recordedExamineFiles, ShouldResemble, expectedValidationFiles)
   227  			So(mv.recordedValidateFiles, ShouldResemble, expectedValidationFiles)
   228  		})
   229  		Convey("Validate error", func() {
   230  			mv.examineResult = &validation.ExamineResult{} //passed
   231  			mv.validateErr = errors.New("something went wrong. Transient but confidential!!!")
   232  			res, err := c.ValidateConfigs(ctx, validateRequest)
   233  			So(err, ShouldHaveGRPCStatus, codes.Internal, "failed to validate the configs")
   234  			So(res, ShouldBeNil)
   235  		})
   236  
   237  		Convey("Doesn't pass examination", func() {
   238  			Convey("Require uploading file", func() {
   239  				vf := validationFile{
   240  					path:   filePath,
   241  					gsPath: gs.MakePath("test-bucket", "validation", "users", "b8a0858b", "configs", "sha256", fileSHA256),
   242  				}
   243  				mv.examineResult = &validation.ExamineResult{
   244  					MissingFiles: []struct {
   245  						File      validation.File
   246  						SignedURL string
   247  					}{
   248  						{
   249  							File:      vf,
   250  							SignedURL: "http://example.com/signed-url",
   251  						},
   252  					},
   253  				}
   254  				mv.validateErr = errors.New("unreachable")
   255  				res, err := c.ValidateConfigs(ctx, validateRequest)
   256  				So(err, ShouldNotBeNil)
   257  				So(res, ShouldBeNil)
   258  				st, ok := status.FromError(err)
   259  				SoMsg("err must be a grpc error status", ok, ShouldBeTrue)
   260  				So(st.Code(), ShouldEqual, codes.InvalidArgument)
   261  				So(st.Message(), ShouldEqual, "invalid validate config request. See status detail for fix instruction.")
   262  				So(st.Details(), ShouldHaveLength, 1)
   263  				So(st.Details()[0], ShouldResembleProto, &configpb.BadValidationRequestFixInfo{
   264  					UploadFiles: []*configpb.BadValidationRequestFixInfo_UploadFile{
   265  						{
   266  							Path:          vf.GetPath(),
   267  							SignedUrl:     "http://example.com/signed-url",
   268  							MaxConfigSize: common.ConfigMaxSize,
   269  						},
   270  					},
   271  				})
   272  				So(mv.recordedExamineFiles, ShouldResemble, []validation.File{vf})
   273  			})
   274  			Convey("Unvalidatable file", func() {
   275  				vf := validationFile{
   276  					path:   filePath,
   277  					gsPath: gs.MakePath("test-bucket", "validation", "users", "b8a0858b", "configs", "sha256", fileSHA256),
   278  				}
   279  				mv.examineResult = &validation.ExamineResult{
   280  					UnvalidatableFiles: []validation.File{vf},
   281  				}
   282  				mv.validateErr = errors.New("unreachable")
   283  				res, err := c.ValidateConfigs(ctx, validateRequest)
   284  				So(err, ShouldNotBeNil)
   285  				So(res, ShouldBeNil)
   286  				st, ok := status.FromError(err)
   287  				SoMsg("err must be a grpc error status", ok, ShouldBeTrue)
   288  				So(st.Code(), ShouldEqual, codes.InvalidArgument)
   289  				So(st.Message(), ShouldEqual, "invalid validate config request. See status detail for fix instruction.")
   290  				So(st.Details(), ShouldHaveLength, 1)
   291  				So(st.Details()[0], ShouldResembleProto, &configpb.BadValidationRequestFixInfo{
   292  					UnvalidatableFiles: []string{vf.GetPath()},
   293  				})
   294  				So(mv.recordedExamineFiles, ShouldResemble, []validation.File{vf})
   295  			})
   296  		})
   297  	})
   298  }