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 }