go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config_service/internal/validation/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 validation 16 17 import ( 18 "bytes" 19 "context" 20 "crypto/rand" 21 "encoding/base64" 22 "encoding/json" 23 "errors" 24 "fmt" 25 "io" 26 "net/http" 27 "net/http/httptest" 28 "testing" 29 30 "cloud.google.com/go/storage" 31 "github.com/golang/mock/gomock" 32 "github.com/klauspost/compress/gzip" 33 "google.golang.org/grpc/codes" 34 "google.golang.org/grpc/status" 35 36 "go.chromium.org/luci/common/gcloud/gs" 37 cfgcommonpb "go.chromium.org/luci/common/proto/config" 38 "go.chromium.org/luci/common/testing/prpctest" 39 "go.chromium.org/luci/config" 40 "go.chromium.org/luci/config/validation" 41 "go.chromium.org/luci/server/auth/authtest" 42 43 "go.chromium.org/luci/config_service/internal/clients" 44 "go.chromium.org/luci/config_service/internal/model" 45 "go.chromium.org/luci/config_service/testutil" 46 47 . "github.com/smartystreets/goconvey/convey" 48 . "go.chromium.org/luci/common/testing/assertions" 49 ) 50 51 type testConsumerServer struct { 52 cfgcommonpb.UnimplementedConsumerServer 53 fileToExpectedURL map[string]string 54 fileToValidationMsgs map[string][]*cfgcommonpb.ValidationResult_Message 55 err error 56 } 57 58 func (srv *testConsumerServer) ValidateConfigs(ctx context.Context, req *cfgcommonpb.ValidateConfigsRequest) (*cfgcommonpb.ValidationResult, error) { 59 if srv.err != nil { 60 return nil, srv.err 61 } 62 result := &cfgcommonpb.ValidationResult{} 63 for _, file := range req.GetFiles().GetFiles() { 64 path := file.GetPath() 65 switch expectedURL, ok := srv.fileToExpectedURL[path]; { 66 case !ok: 67 return nil, status.Errorf(codes.InvalidArgument, "unexpected file %q", path) 68 case file.GetSignedUrl() != expectedURL: 69 return nil, status.Errorf(codes.InvalidArgument, "expected url %q; got %q", expectedURL, file.GetSignedUrl()) 70 } 71 switch msgs, ok := srv.fileToValidationMsgs[path]; { 72 case !ok: 73 return nil, status.Errorf(codes.InvalidArgument, "unexpected file %q", path) 74 default: 75 result.Messages = append(result.Messages, msgs...) 76 } 77 } 78 79 return result, nil 80 } 81 82 type mockFinder struct { 83 mapping map[string][]*model.Service 84 } 85 86 func (m *mockFinder) FindInterestedServices(_ context.Context, _ config.Set, filePath string) []*model.Service { 87 return m.mapping[filePath] 88 } 89 90 type testFile struct { 91 path string 92 gsPath gs.Path 93 content []byte 94 } 95 96 func (tf testFile) GetPath() string { 97 return tf.path 98 } 99 100 func (tf testFile) GetGSPath() gs.Path { 101 return tf.gsPath 102 } 103 104 func (tf testFile) GetRawContent(context.Context) ([]byte, error) { 105 return tf.content, nil 106 } 107 108 var _ File = testFile{} // ensure testFile implements File interface. 109 110 func TestValidate(t *testing.T) { 111 t.Parallel() 112 113 Convey("Validate", t, func() { 114 ctx := testutil.SetupContext() 115 ctx = authtest.MockAuthConfig(ctx) 116 ctl := gomock.NewController(t) 117 mockGsClient := clients.NewMockGsClient(ctl) 118 finder := &mockFinder{} 119 v := &Validator{ 120 GsClient: mockGsClient, 121 Finder: finder, 122 } 123 124 Convey("Single File", func() { 125 cs := config.MustProjectSet("my-project") 126 const filePath = "sub/foo.cfg" 127 const serviceName = "my-service" 128 ts := &prpctest.Server{} 129 srv := &testConsumerServer{} 130 cfgcommonpb.RegisterConsumerServer(ts, srv) 131 ts.Start(ctx) 132 defer ts.Close() 133 134 Convey("No service to validate", func() { 135 res, err := v.Validate(ctx, cs, []File{testFile{path: filePath}}) 136 So(err, ShouldBeNil) 137 So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{}) 138 }) 139 Convey("Validate", func() { 140 const singedURL = "https://example.com/signed" 141 var recordedOpts *storage.SignedURLOptions 142 mockGsClient.EXPECT().SignedURL( 143 gomock.Eq("test-bucket"), 144 gomock.Eq("test-obj"), 145 gomock.AssignableToTypeOf(recordedOpts), 146 ).DoAndReturn( 147 func(_, _ string, opts *storage.SignedURLOptions) (string, error) { 148 recordedOpts = opts 149 return singedURL, nil 150 }, 151 ) 152 153 finder.mapping = map[string][]*model.Service{ 154 filePath: { 155 { 156 Name: serviceName, 157 Info: &cfgcommonpb.Service{ 158 Id: serviceName, 159 Hostname: ts.Host, 160 }, 161 }, 162 }, 163 } 164 Convey("Success", func() { 165 srv.fileToExpectedURL = map[string]string{ 166 filePath: singedURL, 167 } 168 srv.fileToValidationMsgs = map[string][]*cfgcommonpb.ValidationResult_Message{ 169 filePath: { 170 { 171 Path: filePath, 172 Severity: cfgcommonpb.ValidationResult_ERROR, 173 Text: "bad bad bad", 174 }, 175 }, 176 } 177 178 res, err := v.Validate(ctx, cs, []File{ 179 testFile{path: filePath, gsPath: gs.MakePath("test-bucket", "test-obj")}, 180 }) 181 So(err, ShouldBeNil) 182 So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{ 183 Messages: []*cfgcommonpb.ValidationResult_Message{ 184 { 185 Path: filePath, 186 Severity: cfgcommonpb.ValidationResult_ERROR, 187 Text: "bad bad bad", 188 }, 189 }, 190 }) 191 So(recordedOpts.Method, ShouldEqual, http.MethodGet) 192 So(recordedOpts.Headers, ShouldBeEmpty) 193 }) 194 Convey("Error", func() { 195 srv.err = status.Errorf(codes.Internal, "internal server error") 196 197 res, err := v.Validate(ctx, cs, []File{ 198 testFile{path: filePath, gsPath: gs.MakePath("test-bucket", "test-obj")}, 199 }) 200 So(err, ShouldErrLike, "failed to validate configs against service \"my-service\"") 201 So(res, ShouldBeNil) 202 }) 203 }) 204 205 Convey("Validate against self", func() { 206 v.SelfRuleSet = validation.NewRuleSet() 207 finder.mapping = map[string][]*model.Service{ 208 filePath: { 209 { 210 Name: testutil.AppID, 211 Info: &cfgcommonpb.Service{ 212 Id: testutil.AppID, 213 Hostname: ts.Host, 214 }, 215 }, 216 }, 217 } 218 Convey("Succeeds", func() { 219 var validated bool 220 var recordedContent []byte 221 v.SelfRuleSet.Add(string(cs), filePath, func(vCtx *validation.Context, configSet, path string, content []byte) error { 222 validated = true 223 recordedContent = content 224 vCtx.Errorf("bad config") 225 return nil 226 }) 227 tf := testFile{ 228 path: filePath, 229 content: []byte("This is config content"), 230 } 231 res, err := v.Validate(ctx, cs, []File{tf}) 232 So(err, ShouldBeNil) 233 So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{ 234 Messages: []*cfgcommonpb.ValidationResult_Message{ 235 { 236 Path: filePath, 237 Severity: cfgcommonpb.ValidationResult_ERROR, 238 Text: "in \"sub/foo.cfg\": bad config", 239 }, 240 }, 241 }) 242 So(validated, ShouldBeTrue) 243 So(recordedContent, ShouldEqual, tf.content) 244 }) 245 Convey("Error", func() { 246 v.SelfRuleSet.Add(string(cs), filePath, func(vCtx *validation.Context, configSet, path string, content []byte) error { 247 return errors.New("something went wrong") 248 }) 249 tf := testFile{ 250 path: filePath, 251 content: []byte("This is config content"), 252 } 253 res, err := v.Validate(ctx, cs, []File{tf}) 254 So(err, ShouldErrLike, "something went wrong") 255 So(res, ShouldBeNil) 256 }) 257 }) 258 }) 259 260 Convey("Multiple files and services", func() { 261 // test cases: 262 // 4 files: a,b,c,d and 2 services: foo and bar 263 // file a: validated by both foo and bar, foo output 1 warning and bar 264 // output 1 error. 265 // file b: validated by foo, no error or warning 266 // file c: validated by bar, bar returns 1 warning and 1 error. 267 // file d: no service can validate file d 268 fileA := testFile{ 269 path: "a.cfg", 270 gsPath: gs.MakePath("test-bucket", "test-object-a"), 271 } 272 fileB := testFile{ 273 path: "b.cfg", 274 gsPath: gs.MakePath("test-bucket", "test-object-b"), 275 } 276 fileC := testFile{ 277 path: "c.cfg", 278 gsPath: gs.MakePath("test-bucket", "test-object-c"), 279 } 280 fileD := testFile{ 281 path: "d.cfg", 282 gsPath: gs.MakePath("test-bucket", "test-object-d"), 283 } 284 285 testServerFoo := &prpctest.Server{} 286 consumerServerFoo := &testConsumerServer{} 287 cfgcommonpb.RegisterConsumerServer(testServerFoo, consumerServerFoo) 288 testServerFoo.Start(ctx) 289 defer testServerFoo.Close() 290 291 testServerBar := &prpctest.Server{} 292 consumerServerBar := &testConsumerServer{} 293 cfgcommonpb.RegisterConsumerServer(testServerBar, consumerServerBar) 294 testServerBar.Start(ctx) 295 defer testServerBar.Close() 296 297 serviceFoo := &model.Service{ 298 Name: "foo", 299 Info: &cfgcommonpb.Service{ 300 Id: "foo", 301 Hostname: testServerFoo.Host, 302 }, 303 } 304 serviceBar := &model.Service{ 305 Name: "bar", 306 Info: &cfgcommonpb.Service{ 307 Id: "bar", 308 Hostname: testServerBar.Host, 309 }, 310 } 311 312 const signedURLPrefix = "https://example.com/signed" 313 mockGsClient.EXPECT().SignedURL( 314 gomock.Eq("test-bucket"), 315 gomock.Any(), 316 gomock.Any(), 317 ).DoAndReturn( 318 func(bucket, object string, _ *storage.SignedURLOptions) (string, error) { 319 return fmt.Sprintf("%s/%s/%s", signedURLPrefix, bucket, object), nil 320 }, 321 ).AnyTimes() 322 323 finder.mapping = map[string][]*model.Service{ 324 fileA.path: {serviceFoo, serviceBar}, 325 fileB.path: {serviceFoo}, 326 fileC.path: {serviceBar}, 327 // No service can validate fileD. 328 } 329 330 consumerServerFoo.fileToExpectedURL = map[string]string{ 331 fileA.path: "https://example.com/signed/test-bucket/test-object-a", 332 fileB.path: "https://example.com/signed/test-bucket/test-object-b", 333 } 334 consumerServerBar.fileToExpectedURL = map[string]string{ 335 fileA.path: "https://example.com/signed/test-bucket/test-object-a", 336 fileC.path: "https://example.com/signed/test-bucket/test-object-c", 337 } 338 consumerServerFoo.fileToValidationMsgs = map[string][]*cfgcommonpb.ValidationResult_Message{ 339 fileA.path: { 340 { 341 Path: fileA.path, 342 Severity: cfgcommonpb.ValidationResult_WARNING, 343 Text: "warning for file a from service foo", 344 }, 345 }, 346 fileB.path: {}, // No validation error for fileB 347 } 348 consumerServerBar.fileToValidationMsgs = map[string][]*cfgcommonpb.ValidationResult_Message{ 349 fileA.path: { 350 { 351 Path: fileA.path, 352 Severity: cfgcommonpb.ValidationResult_ERROR, 353 Text: "error for file a from service bar", 354 }, 355 }, 356 fileC.path: { 357 { 358 Path: fileC.path, 359 Severity: cfgcommonpb.ValidationResult_WARNING, 360 Text: "warning for file c from service bar", 361 }, 362 { 363 Path: fileC.path, 364 Severity: cfgcommonpb.ValidationResult_ERROR, 365 Text: "error for file c from service bar", 366 }, 367 }, 368 } 369 370 res, err := v.Validate(ctx, config.MustProjectSet("my-project"), []File{fileA, fileB, fileC, fileD}) 371 So(err, ShouldBeNil) 372 So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{ 373 Messages: []*cfgcommonpb.ValidationResult_Message{ 374 { 375 Path: fileA.path, 376 Severity: cfgcommonpb.ValidationResult_ERROR, 377 Text: "error for file a from service bar", 378 }, 379 { 380 Path: fileA.path, 381 Severity: cfgcommonpb.ValidationResult_WARNING, 382 Text: "warning for file a from service foo", 383 }, 384 { 385 Path: fileC.path, 386 Severity: cfgcommonpb.ValidationResult_ERROR, 387 Text: "error for file c from service bar", 388 }, 389 { 390 Path: fileC.path, 391 Severity: cfgcommonpb.ValidationResult_WARNING, 392 Text: "warning for file c from service bar", 393 }, 394 }, 395 }) 396 }) 397 }) 398 } 399 400 func TestValidateLegacy(t *testing.T) { 401 t.Parallel() 402 403 Convey("Validate using legacy protocol", t, func() { 404 ctx := testutil.SetupContext() 405 ctx = authtest.MockAuthConfig(ctx) 406 ctl := gomock.NewController(t) 407 mockGsClient := clients.NewMockGsClient(ctl) 408 finder := &mockFinder{} 409 v := &Validator{ 410 GsClient: mockGsClient, 411 Finder: finder, 412 } 413 414 var srvResponse []byte 415 var srvErrMsg string 416 var capturedRequestBody []byte 417 var capturedRequestHeader http.Header 418 legacyTestSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 419 capturedRequestHeader = r.Header 420 var err error 421 if capturedRequestBody, err = io.ReadAll(r.Body); err != nil { 422 w.WriteHeader(http.StatusInternalServerError) 423 fmt.Fprintf(w, "%s", err) 424 return 425 } 426 if srvErrMsg != "" { 427 w.WriteHeader(http.StatusInternalServerError) 428 fmt.Fprint(w, srvErrMsg) 429 return 430 } 431 if _, err := w.Write(srvResponse); err != nil { 432 w.WriteHeader(http.StatusInternalServerError) 433 fmt.Fprintf(w, "failed to write response: %s", err) 434 } 435 })) 436 defer legacyTestSrv.Close() 437 438 cs := config.MustProjectSet("my-project") 439 const filePath = "sub/foo.cfg" 440 const serviceName = "my-service" 441 finder.mapping = map[string][]*model.Service{ 442 filePath: { 443 { 444 Name: serviceName, 445 Info: &cfgcommonpb.Service{ 446 Id: serviceName, 447 MetadataUrl: legacyTestSrv.URL, 448 }, 449 LegacyMetadata: &cfgcommonpb.ServiceDynamicMetadata{ 450 Version: "1.0", 451 Validation: &cfgcommonpb.Validator{ 452 Url: legacyTestSrv.URL, 453 Patterns: []*cfgcommonpb.ConfigPattern{ 454 {ConfigSet: string(cs), Path: filePath}, 455 }, 456 }, 457 SupportsGzipCompression: true, 458 }, 459 }, 460 }, 461 } 462 463 Convey("Works", func() { 464 Convey("With int severity", func() { 465 srvResponse = []byte(`{"messages": [{"severity": 40, "text": "bad config"}]}`) 466 }) 467 Convey("With string severity", func() { 468 srvResponse = []byte(`{"messages": [{"severity": "ERROR", "text": "bad config"}]}`) 469 }) 470 tf := testFile{ 471 path: filePath, 472 content: []byte("This is config content"), 473 } 474 res, err := v.Validate(ctx, cs, []File{tf}) 475 So(err, ShouldBeNil) 476 So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{ 477 Messages: []*cfgcommonpb.ValidationResult_Message{ 478 { 479 Path: filePath, 480 Severity: cfgcommonpb.ValidationResult_ERROR, 481 Text: "bad config", 482 }, 483 }, 484 }) 485 486 So(capturedRequestBody, ShouldNotBeEmpty) 487 reqMap := map[string]any{} 488 So(json.Unmarshal(capturedRequestBody, &reqMap), ShouldBeNil) 489 So(reqMap, ShouldHaveLength, 3) 490 So(reqMap["config_set"], ShouldEqual, "projects/my-project") 491 So(reqMap["path"], ShouldEqual, filePath) 492 So(reqMap["content"], ShouldEqual, base64.StdEncoding.EncodeToString(tf.content)) 493 So(capturedRequestHeader.Get("Content-Type"), ShouldEqual, "application/json; charset=utf-8") 494 So(capturedRequestHeader.Get("Content-Encoding"), ShouldBeEmpty) 495 }) 496 497 Convey("Empty messages", func() { 498 srvResponse = []byte(`{"messages": []}`) 499 tf := testFile{ 500 path: filePath, 501 content: []byte("This is config content"), 502 } 503 res, err := v.Validate(ctx, cs, []File{tf}) 504 So(err, ShouldBeNil) 505 So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{}) 506 }) 507 508 Convey("Empty response", func() { 509 srvResponse = nil 510 tf := testFile{ 511 path: filePath, 512 content: []byte("This is config content"), 513 } 514 res, err := v.Validate(ctx, cs, []File{tf}) 515 So(err, ShouldBeNil) 516 So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{}) 517 }) 518 519 Convey("Compress large payload", func() { 520 tf := testFile{ 521 path: filePath, 522 content: make([]byte, 1024*1024), 523 } 524 _, err := rand.Read(tf.content) 525 So(err, ShouldBeNil) 526 res, err := v.Validate(ctx, cs, []File{tf}) 527 So(err, ShouldBeNil) 528 So(res, ShouldNotBeNil) 529 530 So(capturedRequestBody, ShouldNotBeEmpty) 531 r, err := gzip.NewReader(bytes.NewBuffer(capturedRequestBody)) 532 So(err, ShouldBeNil) 533 uncompressed, err := io.ReadAll(r) 534 So(err, ShouldBeNil) 535 reqMap := map[string]any{} 536 So(json.Unmarshal(uncompressed, &reqMap), ShouldBeNil) 537 So(reqMap, ShouldHaveLength, 3) 538 So(reqMap["content"], ShouldEqual, base64.StdEncoding.EncodeToString(tf.content)) 539 So(capturedRequestHeader.Get("Content-Type"), ShouldEqual, "application/json; charset=utf-8") 540 So(capturedRequestHeader.Get("Content-Encoding"), ShouldEqual, "gzip") 541 }) 542 543 Convey("Omit unknown severity", func() { 544 Convey("Not provided", func() { 545 srvResponse = []byte(`{"messages": [{"text": "bad config"}]}`) 546 }) 547 Convey("Returns unknown", func() { 548 srvResponse = []byte(`{"messages": [{"severity": 0, "text": "bad config"}]}`) 549 }) 550 551 tf := testFile{ 552 path: filePath, 553 content: []byte("This is config content"), 554 } 555 res, err := v.Validate(ctx, cs, []File{tf}) 556 So(err, ShouldBeNil) 557 So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{}) 558 }) 559 560 Convey("Error on unrecognized severity", func() { 561 Convey("Int severity", func() { 562 srvResponse = []byte(`{"messages": [{"severity": 1234, "text": "bad config"}]}`) 563 }) 564 Convey("String severity", func() { 565 srvResponse = []byte(`{"messages": [{"severity": "BAD", "text": "bad config"}]}`) 566 }) 567 Convey("Not int not string", func() { 568 srvResponse = []byte(`{"messages": [{"severity": true, "text": "bad config"}]}`) 569 }) 570 tf := testFile{ 571 path: filePath, 572 content: []byte("This is config content"), 573 } 574 res, err := v.Validate(ctx, cs, []File{tf}) 575 So(err, ShouldErrLike, "unrecognized severity") 576 So(res, ShouldBeNil) 577 }) 578 579 Convey("Server Error", func() { 580 srvErrMsg = "server encounter error" 581 tf := testFile{ 582 path: filePath, 583 content: []byte("This is config content"), 584 } 585 res, err := v.Validate(ctx, cs, []File{tf}) 586 So(err, ShouldErrLike, legacyTestSrv.URL+" returns 500") 587 So(res, ShouldBeNil) 588 }) 589 590 Convey("Server returns malformed response", func() { 591 srvResponse = []byte("[") 592 tf := testFile{ 593 path: filePath, 594 content: []byte("This is config content"), 595 } 596 res, err := v.Validate(ctx, cs, []File{tf}) 597 So(err, ShouldErrLike, "failed to unmarshal response") 598 So(res, ShouldBeNil) 599 }) 600 }) 601 }