go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/config/validate_test.go (about) 1 // Copyright 2022 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 config 16 17 import ( 18 "context" 19 "fmt" 20 "math" 21 "os" 22 "strings" 23 "testing" 24 25 "google.golang.org/protobuf/encoding/prototext" 26 "google.golang.org/protobuf/proto" 27 28 "go.chromium.org/luci/config/validation" 29 30 "go.chromium.org/luci/analysis/internal/analysis/metrics" 31 configpb "go.chromium.org/luci/analysis/proto/config" 32 33 . "github.com/smartystreets/goconvey/convey" 34 . "go.chromium.org/luci/common/testing/assertions" 35 ) 36 37 const project = "fakeproject" 38 const chromiumMilestoneProject = "chrome-m101" 39 40 func TestServiceConfigValidator(t *testing.T) { 41 t.Parallel() 42 43 validate := func(cfg *configpb.Config) error { 44 c := validation.Context{Context: context.Background()} 45 validateConfig(&c, cfg) 46 return c.Finalize() 47 } 48 49 Convey("config template is valid", t, func() { 50 content, err := os.ReadFile( 51 "../../configs/services/luci-analysis-dev/config-template.cfg", 52 ) 53 So(err, ShouldBeNil) 54 cfg := &configpb.Config{} 55 So(prototext.Unmarshal(content, cfg), ShouldBeNil) 56 So(validate(cfg), ShouldBeNil) 57 }) 58 59 Convey("valid config is valid", t, func() { 60 cfg, err := CreatePlaceholderConfig() 61 So(err, ShouldBeNil) 62 63 So(validate(cfg), ShouldBeNil) 64 }) 65 66 Convey("monorail hostname", t, func() { 67 cfg, err := CreatePlaceholderConfig() 68 So(err, ShouldBeNil) 69 70 Convey("must be specified", func() { 71 cfg.MonorailHostname = "" 72 So(validate(cfg), ShouldErrLike, "(monorail_hostname): must be specified") 73 }) 74 Convey("must be correctly formed", func() { 75 cfg.MonorailHostname = "monorail host" 76 So(validate(cfg), ShouldErrLike, `(monorail_hostname): does not match pattern "^[a-z][a-z9-9\\-.]{0,62}[a-z]$"`) 77 }) 78 }) 79 Convey("chunk GCS bucket", t, func() { 80 cfg, err := CreatePlaceholderConfig() 81 So(err, ShouldBeNil) 82 83 Convey("must be specified", func() { 84 cfg.ChunkGcsBucket = "" 85 So(validate(cfg), ShouldErrLike, `(chunk_gcs_bucket): must be specified`) 86 }) 87 Convey("must be correctly formed", func() { 88 cfg, err := CreatePlaceholderConfig() 89 So(err, ShouldBeNil) 90 91 cfg.ChunkGcsBucket = "my bucket" 92 So(validate(cfg), ShouldErrLike, `(chunk_gcs_bucket): does not match pattern "^[a-z0-9][a-z0-9\\-_.]{1,220}[a-z0-9]$"`) 93 }) 94 }) 95 Convey("reclustering workers", t, func() { 96 cfg, err := CreatePlaceholderConfig() 97 So(err, ShouldBeNil) 98 99 Convey("zero", func() { 100 cfg.ReclusteringWorkers = 0 101 So(validate(cfg), ShouldErrLike, `(reclustering_workers): must be specified`) 102 }) 103 Convey("less than one", func() { 104 cfg.ReclusteringWorkers = -1 105 So(validate(cfg), ShouldErrLike, `(reclustering_workers): must be in the range [1, 1000]`) 106 }) 107 Convey("too large", func() { 108 cfg.ReclusteringWorkers = 1001 109 So(validate(cfg), ShouldErrLike, `(reclustering_workers): must be in the range [1, 1000]`) 110 }) 111 }) 112 } 113 114 func TestProjectConfigValidator(t *testing.T) { 115 t.Parallel() 116 117 validate := func(project string, cfg *configpb.ProjectConfig) error { 118 c := validation.Context{Context: context.Background()} 119 ValidateProjectConfig(&c, project, cfg) 120 return c.Finalize() 121 } 122 123 Convey("config template is valid", t, func() { 124 content, err := os.ReadFile( 125 "../../configs/projects/chromium/luci-analysis-dev-template.cfg", 126 ) 127 So(err, ShouldBeNil) 128 cfg := &configpb.ProjectConfig{} 129 So(prototext.Unmarshal(content, cfg), ShouldBeNil) 130 So(validate(project, cfg), ShouldBeNil) 131 }) 132 133 Convey("clustering", t, func() { 134 cfg := CreateConfigWithBothBuganizerAndMonorail(configpb.BugSystem_MONORAIL) 135 136 clustering := cfg.Clustering 137 138 Convey("may not be specified", func() { 139 cfg.Clustering = nil 140 So(validate(project, cfg), ShouldBeNil) 141 }) 142 Convey("test name rules", func() { 143 rule := clustering.TestNameRules[0] 144 path := `clustering / test_name_rules / [0]` 145 Convey("name", func() { 146 Convey("unset", func() { 147 rule.Name = "" 148 So(validate(project, cfg), ShouldErrLike, `(`+path+` / name): must be specified`) 149 }) 150 Convey("invalid", func() { 151 rule.Name = "<script>evil()</script>" 152 So(validate(project, cfg), ShouldErrLike, `(`+path+` / name): does not match pattern "^[a-zA-Z0-9\\-(), ]+$"`) 153 }) 154 }) 155 Convey("pattern", func() { 156 Convey("unset", func() { 157 rule.Pattern = "" 158 // Make sure the like template does not refer to capture 159 // groups in the pattern, to avoid other errors in this test. 160 rule.LikeTemplate = "%blah%" 161 So(validate(project, cfg), ShouldErrLike, `(`+path+` / pattern): must be specified`) 162 }) 163 Convey("invalid", func() { 164 rule.Pattern = "[" 165 So(validate(project, cfg), ShouldErrLike, `(`+path+`): pattern: error parsing regexp: missing closing ]`) 166 }) 167 }) 168 Convey("like pattern", func() { 169 Convey("unset", func() { 170 rule.LikeTemplate = "" 171 So(validate(project, cfg), ShouldErrLike, `(`+path+` / like_template): must be specified`) 172 }) 173 Convey("invalid", func() { 174 rule.LikeTemplate = "blah${broken" 175 So(validate(project, cfg), ShouldErrLike, `(`+path+`): like_template: invalid use of the $ operator at position 4 in "blah${broken"`) 176 }) 177 }) 178 }) 179 Convey("failure reason masks", func() { 180 Convey("empty", func() { 181 clustering.ReasonMaskPatterns = nil 182 So(validate(project, cfg), ShouldBeNil) 183 }) 184 Convey("pattern is not specified", func() { 185 clustering.ReasonMaskPatterns[0] = "" 186 So(validate(project, cfg), ShouldErrLike, "empty pattern is not allowed") 187 }) 188 Convey("pattern is invalid", func() { 189 clustering.ReasonMaskPatterns[0] = "[" 190 So(validate(project, cfg), ShouldErrLike, "could not compile pattern: error parsing regexp: missing closing ]") 191 }) 192 Convey("pattern has multiple subexpressions", func() { 193 clustering.ReasonMaskPatterns[0] = `(a)(b)` 194 So(validate(project, cfg), ShouldErrLike, "pattern must contain exactly one parenthesised capturing subexpression indicating the text to mask") 195 }) 196 Convey("non-capturing subexpressions does not count", func() { 197 clustering.ReasonMaskPatterns[0] = `^(?:\[Fixture failure\]) ([a-zA-Z0-9_]+)(?:[:])` 198 So(validate(project, cfg), ShouldBeNil) 199 }) 200 }) 201 }) 202 Convey("metrics", t, func() { 203 cfg := CreateConfigWithBothBuganizerAndMonorail(configpb.BugSystem_MONORAIL) 204 205 metrics := cfg.Metrics 206 207 Convey("may be left unspecified", func() { 208 cfg.Metrics = nil 209 So(validate(project, cfg), ShouldBeNil) 210 }) 211 Convey("overrides must be valid", func() { 212 override := metrics.Overrides[0] 213 Convey("metric ID is not specified", func() { 214 override.MetricId = "" 215 So(validate(project, cfg), ShouldErrLike, `no metric with ID ""`) 216 }) 217 Convey("metric ID is invalid", func() { 218 override.MetricId = "not-exists" 219 So(validate(project, cfg), ShouldErrLike, `no metric with ID "not-exists"`) 220 }) 221 Convey("metric ID is repeated", func() { 222 metrics.Overrides[0].MetricId = "failures" 223 metrics.Overrides[1].MetricId = "failures" 224 So(validate(project, cfg), ShouldErrLike, `metric with ID "failures" appears in collection more than once`) 225 }) 226 Convey("sort priority is invalid", func() { 227 override.SortPriority = proto.Int32(0) 228 So(validate(project, cfg), ShouldErrLike, `value must be positive`) 229 }) 230 }) 231 }) 232 Convey("bug management", t, func() { 233 So(printableASCIIRE.MatchString("ninja:${target}/%${suite}.${case}%"), ShouldBeTrue) 234 cfg := CreateConfigWithBothBuganizerAndMonorail(configpb.BugSystem_BUGANIZER) 235 bm := cfg.BugManagement 236 237 Convey("may be unspecified", func() { 238 // E.g. if project does not want to use bug management capabilities. 239 cfg.BugManagement = nil 240 So(validate(project, cfg), ShouldBeNil) 241 }) 242 Convey("may be empty", func() { 243 // E.g. if project does not want to use bug management capabilities. 244 cfg.BugManagement = &configpb.BugManagement{} 245 So(validate(project, cfg), ShouldBeNil) 246 }) 247 Convey("default bug system must be set if monorail or buganizer configured", func() { 248 bm.DefaultBugSystem = configpb.BugSystem_BUG_SYSTEM_UNSPECIFIED 249 So(validate(project, cfg), ShouldErrLike, `(bug_management / default_bug_system): must be specified`) 250 }) 251 Convey("buganizer", func() { 252 b := bm.Buganizer 253 Convey("may be unset", func() { 254 bm.DefaultBugSystem = configpb.BugSystem_MONORAIL 255 bm.Buganizer = nil 256 So(validate(project, cfg), ShouldBeNil) 257 258 Convey("but not if buganizer is default bug system", func() { 259 bm.DefaultBugSystem = configpb.BugSystem_BUGANIZER 260 So(validate(project, cfg), ShouldErrLike, `(bug_management): buganizer section is required when the default_bug_system is Buganizer`) 261 }) 262 }) 263 Convey("default component", func() { 264 path := `bug_management / buganizer / default_component` 265 Convey("must be set", func() { 266 b.DefaultComponent = nil 267 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 268 }) 269 Convey("id must be set", func() { 270 b.DefaultComponent.Id = 0 271 So(validate(project, cfg), ShouldErrLike, `(`+path+` / id): must be specified`) 272 }) 273 Convey("id is non-positive", func() { 274 b.DefaultComponent.Id = -1 275 So(validate(project, cfg), ShouldErrLike, `(`+path+` / id): must be positive`) 276 }) 277 }) 278 }) 279 Convey("monorail", func() { 280 m := bm.Monorail 281 path := `bug_management / monorail` 282 Convey("may be unset", func() { 283 bm.DefaultBugSystem = configpb.BugSystem_BUGANIZER 284 bm.Monorail = nil 285 So(validate(project, cfg), ShouldBeNil) 286 287 Convey("but not if monorail is default bug system", func() { 288 bm.DefaultBugSystem = configpb.BugSystem_MONORAIL 289 So(validate(project, cfg), ShouldErrLike, `(bug_management): monorail section is required when the default_bug_system is Monorail`) 290 }) 291 }) 292 Convey("project", func() { 293 path := path + ` / project` 294 Convey("unset", func() { 295 m.Project = "" 296 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 297 }) 298 Convey("invalid", func() { 299 m.Project = "<>" 300 So(validate(project, cfg), ShouldErrLike, `(`+path+`): does not match pattern "^[a-z0-9][-a-z0-9]{0,61}[a-z0-9]$"`) 301 }) 302 }) 303 Convey("monorail hostname", func() { 304 path := path + ` / monorail_hostname` 305 Convey("unset", func() { 306 m.MonorailHostname = "" 307 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 308 }) 309 Convey("invalid", func() { 310 m.MonorailHostname = "<>" 311 So(validate(project, cfg), ShouldErrLike, `(`+path+`): does not match pattern "^[a-z][a-z9-9\\-.]{0,62}[a-z]$"`) 312 }) 313 }) 314 Convey("display prefix", func() { 315 path := path + ` / display_prefix` 316 Convey("unset", func() { 317 m.DisplayPrefix = "" 318 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 319 }) 320 Convey("invalid", func() { 321 m.DisplayPrefix = "<>" 322 So(validate(project, cfg), ShouldErrLike, `(`+path+`): does not match pattern "^[a-z0-9\\-.]{0,64}$"`) 323 }) 324 }) 325 Convey("priority field id", func() { 326 path := path + ` / priority_field_id` 327 Convey("unset", func() { 328 m.PriorityFieldId = 0 329 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 330 }) 331 Convey("invalid", func() { 332 m.PriorityFieldId = -1 333 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be positive`) 334 }) 335 }) 336 Convey("default field values", func() { 337 path := path + ` / default_field_values` 338 fieldValue := m.DefaultFieldValues[0] 339 Convey("empty", func() { 340 // Valid to have no default values. 341 m.DefaultFieldValues = nil 342 So(validate(project, cfg), ShouldBeNil) 343 }) 344 Convey("too many", func() { 345 m.DefaultFieldValues = make([]*configpb.MonorailFieldValue, 0, 51) 346 for i := 0; i < 51; i++ { 347 m.DefaultFieldValues = append(m.DefaultFieldValues, &configpb.MonorailFieldValue{ 348 FieldId: int64(i + 1), 349 Value: "value", 350 }) 351 } 352 m.DefaultFieldValues[0].Value = `\0` 353 So(validate(project, cfg), ShouldErrLike, `(`+path+`): at most 50 field values may be specified`) 354 }) 355 Convey("unset", func() { 356 m.DefaultFieldValues[0] = nil 357 So(validate(project, cfg), ShouldErrLike, `(`+path+` / [0]): must be specified`) 358 }) 359 Convey("invalid - unset field ID", func() { 360 fieldValue.FieldId = 0 361 So(validate(project, cfg), ShouldErrLike, `(`+path+` / [0] / field_id): must be specified`) 362 }) 363 Convey("invalid - bad field value", func() { 364 fieldValue.Value = "\x00" 365 So(validate(project, cfg), ShouldErrLike, `(`+path+` / [0] / value): does not match pattern "^[[:print:]]+$"`) 366 }) 367 }) 368 }) 369 Convey("policies", func() { 370 policy := bm.Policies[0] 371 path := "bug_management / policies" 372 Convey("may be empty", func() { 373 bm.Policies = nil 374 So(validate(project, cfg), ShouldBeNil) 375 }) 376 // but may have non-duplicate IDs. 377 Convey("may have multiple", func() { 378 bm.Policies = []*configpb.BugManagementPolicy{ 379 CreatePlaceholderBugManagementPolicy("policy-a"), 380 CreatePlaceholderBugManagementPolicy("policy-b"), 381 } 382 So(validate(project, cfg), ShouldBeNil) 383 384 Convey("duplicate policy IDs", func() { 385 bm.Policies[1].Id = bm.Policies[0].Id 386 So(validate(project, cfg), ShouldErrLike, `(`+path+` / [1] / id): policy with ID "policy-a" appears in the collection more than once`) 387 }) 388 }) 389 Convey("too many", func() { 390 bm.Policies = []*configpb.BugManagementPolicy{} 391 for i := 0; i < 51; i++ { 392 policy := CreatePlaceholderBugManagementPolicy(fmt.Sprintf("extra-%v", i)) 393 bm.Policies = append(bm.Policies, policy) 394 } 395 So(validate(project, cfg), ShouldErrLike, `(`+path+`): exceeds maximum of 50 policies`) 396 }) 397 Convey("unset", func() { 398 bm.Policies[0] = nil 399 So(validate(project, cfg), ShouldErrLike, `(`+path+` / [0]): must be specified`) 400 }) 401 Convey("id", func() { 402 path := path + " / [0] / id" 403 Convey("unset", func() { 404 policy.Id = "" 405 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 406 }) 407 Convey("invalid", func() { 408 policy.Id = "-a-" 409 So(validate(project, cfg), ShouldErrLike, `(`+path+`): does not match pattern "^[a-z]([a-z0-9-]{0,62}[a-z0-9])?$"`) 410 }) 411 Convey("too long", func() { 412 policy.Id = strings.Repeat("a", 65) 413 So(validate(project, cfg), ShouldErrLike, `(`+path+`): exceeds maximum allowed length of 64 bytes`) 414 }) 415 }) 416 Convey("human readable name", func() { 417 path := path + " / [0] / human_readable_name" 418 Convey("unset", func() { 419 policy.HumanReadableName = "" 420 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 421 }) 422 Convey("invalid", func() { 423 policy.HumanReadableName = "\x00" 424 So(validate(project, cfg), ShouldErrLike, `(`+path+`): does not match pattern "^[[:print:]]{1,100}$"`) 425 }) 426 Convey("too long", func() { 427 policy.HumanReadableName = strings.Repeat("a", 101) 428 So(validate(project, cfg), ShouldErrLike, `(`+path+`): exceeds maximum allowed length of 100 bytes`) 429 }) 430 }) 431 Convey("owners", func() { 432 path := path + " / [0] / owners" 433 Convey("unset", func() { 434 policy.Owners = nil 435 So(validate(project, cfg), ShouldErrLike, `(`+path+`): at least one owner must be specified`) 436 }) 437 Convey("too many", func() { 438 policy.Owners = []string{} 439 for i := 0; i < 11; i++ { 440 policy.Owners = append(policy.Owners, "blah@google.com") 441 } 442 So(validate(project, cfg), ShouldErrLike, `(`+path+`): exceeds maximum of 10 owners`) 443 }) 444 Convey("invalid - empty", func() { 445 // Must have a @google.com owner. 446 policy.Owners = []string{""} 447 So(validate(project, cfg), ShouldErrLike, `(`+path+` / [0]): must be specified`) 448 }) 449 Convey("invalid - non @google.com", func() { 450 // Must have a @google.com owner. 451 policy.Owners = []string{"blah@blah.com"} 452 So(validate(project, cfg), ShouldErrLike, `(`+path+" / [0]): does not match pattern \"^[A-Za-z0-9!#$%&'*+-/=?^_`.{|}~]{1,64}@google\\\\.com$\"") 453 }) 454 Convey("invalid - too long", func() { 455 policy.Owners = []string{strings.Repeat("a", 65) + "@google.com"} 456 So(validate(project, cfg), ShouldErrLike, `(`+path+` / [0]): exceeds maximum allowed length of 75 bytes`) 457 }) 458 }) 459 Convey("priority", func() { 460 path := path + " / [0] / priority" 461 Convey("unset", func() { 462 policy.Priority = configpb.BuganizerPriority_BUGANIZER_PRIORITY_UNSPECIFIED 463 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 464 }) 465 }) 466 Convey("metrics", func() { 467 metric := policy.Metrics[0] 468 path := path + " / [0] / metrics" 469 Convey("unset", func() { 470 policy.Metrics = nil 471 So(validate(project, cfg), ShouldErrLike, `(`+path+`): at least one metric must be specified`) 472 }) 473 Convey("multiple", func() { 474 policy.Metrics = []*configpb.BugManagementPolicy_Metric{ 475 { 476 MetricId: metrics.CriticalFailuresExonerated.ID.String(), 477 ActivationThreshold: &configpb.MetricThreshold{ 478 OneDay: proto.Int64(50), 479 }, 480 DeactivationThreshold: &configpb.MetricThreshold{ 481 ThreeDay: proto.Int64(1), 482 }, 483 }, 484 { 485 MetricId: metrics.BuildsFailedDueToFlakyTests.ID.String(), 486 ActivationThreshold: &configpb.MetricThreshold{ 487 OneDay: proto.Int64(50), 488 }, 489 DeactivationThreshold: &configpb.MetricThreshold{ 490 ThreeDay: proto.Int64(1), 491 }, 492 }, 493 } 494 // Valid 495 So(validate(project, cfg), ShouldBeNil) 496 497 Convey("duplicate IDs", func() { 498 // Invalid. 499 policy.Metrics[1].MetricId = policy.Metrics[0].MetricId 500 So(validate(project, cfg), ShouldErrLike, `(`+path+` / [1] / metric_id): metric with ID "critical-failures-exonerated" appears in collection more than once`) 501 }) 502 Convey("too many", func() { 503 policy.Metrics = []*configpb.BugManagementPolicy_Metric{} 504 for i := 0; i < 11; i++ { 505 policy.Metrics = append(policy.Metrics, &configpb.BugManagementPolicy_Metric{ 506 MetricId: fmt.Sprintf("metric-%v", i), 507 ActivationThreshold: &configpb.MetricThreshold{ 508 OneDay: proto.Int64(50), 509 }, 510 DeactivationThreshold: &configpb.MetricThreshold{ 511 ThreeDay: proto.Int64(1), 512 }, 513 }) 514 } 515 So(validate(project, cfg), ShouldErrLike, `(`+path+`): exceeds maximum of 10 metrics`) 516 }) 517 }) 518 Convey("metric ID", func() { 519 Convey("unset", func() { 520 metric.MetricId = "" 521 So(validate(project, cfg), ShouldErrLike, `(`+path+` / [0] / metric_id): no metric with ID ""`) 522 }) 523 Convey("invalid - metric not defined", func() { 524 metric.MetricId = "not-exists" 525 So(validate(project, cfg), ShouldErrLike, `(`+path+` / [0] / metric_id): no metric with ID "not-exists"`) 526 }) 527 }) 528 Convey("activation threshold", func() { 529 path := path + " / [0] / activation_threshold" 530 Convey("unset", func() { 531 // An activation threshold is not required, e.g. in case of 532 // policies which are paused or being removed, but where 533 // existing policy activations are to be kept. 534 metric.ActivationThreshold = nil 535 So(validate(project, cfg), ShouldBeNil) 536 }) 537 Convey("may be empty", func() { 538 // An activation threshold is not required, e.g. in case of 539 // policies which are paused or being removed, but where 540 // existing policy activations are to be kept. 541 metric.ActivationThreshold = &configpb.MetricThreshold{} 542 So(validate(project, cfg), ShouldBeNil) 543 }) 544 Convey("invalid - non-positive threshold", func() { 545 metric.ActivationThreshold.ThreeDay = proto.Int64(0) 546 So(validate(project, cfg), ShouldErrLike, `(`+path+` / three_day): value must be positive`) 547 }) 548 Convey("invalid - too large threshold", func() { 549 metric.ActivationThreshold.SevenDay = proto.Int64(1000 * 1000 * 1000) 550 So(validate(project, cfg), ShouldErrLike, `(`+path+` / seven_day): value must be less than one million`) 551 }) 552 }) 553 Convey("deactivation threshold", func() { 554 path := path + " / [0] / deactivation_threshold" 555 Convey("unset", func() { 556 metric.DeactivationThreshold = nil 557 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 558 }) 559 Convey("empty", func() { 560 // There must always be a way for a policy to deactivate. 561 metric.DeactivationThreshold = &configpb.MetricThreshold{} 562 So(validate(project, cfg), ShouldErrLike, `(`+path+`): at least one of one_day, three_day and seven_day must be set`) 563 }) 564 Convey("invalid - non-positive threshold", func() { 565 metric.DeactivationThreshold.OneDay = proto.Int64(0) 566 So(validate(project, cfg), ShouldErrLike, `(`+path+` / one_day): value must be positive`) 567 }) 568 Convey("invalid - too large threshold", func() { 569 metric.DeactivationThreshold.ThreeDay = proto.Int64(1000 * 1000 * 1000) 570 So(validate(project, cfg), ShouldErrLike, `(`+path+` / three_day): value must be less than one million`) 571 }) 572 }) 573 }) 574 Convey("explanation", func() { 575 path := path + " / [0] / explanation" 576 Convey("unset", func() { 577 policy.Explanation = nil 578 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 579 }) 580 explanation := policy.Explanation 581 Convey("problem html", func() { 582 path := path + " / problem_html" 583 Convey("unset", func() { 584 explanation.ProblemHtml = "" 585 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 586 }) 587 Convey("invalid UTF-8", func() { 588 explanation.ProblemHtml = "\xc3\x28" 589 So(validate(project, cfg), ShouldErrLike, `(`+path+`): not a valid UTF-8 string`) 590 }) 591 Convey("invalid rune", func() { 592 explanation.ProblemHtml = "a\x00" 593 So(validate(project, cfg), ShouldErrLike, `(`+path+`): unicode rune '\x00' at index 1 is not graphic or newline character`) 594 }) 595 Convey("too long", func() { 596 explanation.ProblemHtml = strings.Repeat("a", 10001) 597 So(validate(project, cfg), ShouldErrLike, `(`+path+`): exceeds maximum allowed length of 10000 bytes`) 598 }) 599 }) 600 Convey("action html", func() { 601 path := path + " / action_html" 602 Convey("unset", func() { 603 explanation.ActionHtml = "" 604 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 605 }) 606 Convey("invalid UTF-8", func() { 607 explanation.ActionHtml = "\xc3\x28" 608 So(validate(project, cfg), ShouldErrLike, `(`+path+`): not a valid UTF-8 string`) 609 }) 610 Convey("invalid", func() { 611 explanation.ActionHtml = "a\x00" 612 So(validate(project, cfg), ShouldErrLike, `(`+path+`): unicode rune '\x00' at index 1 is not graphic or newline character`) 613 }) 614 Convey("too long", func() { 615 explanation.ActionHtml = strings.Repeat("a", 10001) 616 So(validate(project, cfg), ShouldErrLike, `(`+path+`): exceeds maximum allowed length of 10000 bytes`) 617 }) 618 }) 619 }) 620 Convey("bug template", func() { 621 path := path + " / [0] / bug_template" 622 Convey("unset", func() { 623 policy.BugTemplate = nil 624 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 625 }) 626 bugTemplate := policy.BugTemplate 627 Convey("comment template", func() { 628 path := path + " / comment_template" 629 Convey("unset", func() { 630 // May be left blank to post no comment. 631 bugTemplate.CommentTemplate = "" 632 So(validate(project, cfg), ShouldBeNil) 633 }) 634 Convey("too long", func() { 635 bugTemplate.CommentTemplate = strings.Repeat("a", 10001) 636 So(validate(project, cfg), ShouldErrLike, `(`+path+`): exceeds maximum allowed length of 10000 bytes`) 637 }) 638 Convey("invalid - not valid UTF-8", func() { 639 bugTemplate.CommentTemplate = "\xc3\x28" 640 So(validate(project, cfg), ShouldErrLike, `(`+path+`): not a valid UTF-8 string`) 641 }) 642 Convey("invalid - non-ASCII characters", func() { 643 bugTemplate.CommentTemplate = "a\x00" 644 So(validate(project, cfg), ShouldErrLike, `(`+path+`): unicode rune '\x00' at index 1 is not graphic or newline character`) 645 }) 646 Convey("invalid - bad field reference", func() { 647 bugTemplate.CommentTemplate = "{{.FieldNotExisting}}" 648 649 err := validate(project, cfg) 650 So(err, ShouldErrLike, `(`+path+`): validate template: `) 651 So(err, ShouldErrLike, `can't evaluate field FieldNotExisting`) 652 }) 653 Convey("invalid - bad function reference", func() { 654 bugTemplate.CommentTemplate = "{{call SomeFunc}}" 655 656 err := validate(project, cfg) 657 So(err, ShouldErrLike, `(`+path+`): parsing template: `) 658 So(err, ShouldErrLike, `function "SomeFunc" not defined`) 659 }) 660 Convey("invalid - output too long on simulated examples", func() { 661 // Produces 10100 letter 'a's through nested templates, which 662 // exceeds the output length limit. 663 bugTemplate.CommentTemplate = 664 `{{define "T1"}}` + strings.Repeat("a", 100) + `{{end}}` + 665 `{{define "T2"}}` + strings.Repeat(`{{template "T1"}}`, 101) + `{{end}}` + 666 `{{template "T2"}}` 667 668 err := validate(project, cfg) 669 So(err, ShouldErrLike, `(`+path+`): validate template: `) 670 So(err, ShouldErrLike, `template produced 10100 bytes of output, which exceeds the limit of 10000 bytes`) 671 }) 672 Convey("invalid - does not handle monorail bug", func() { 673 // Unqualified access of Buganizer Bug ID without checking bug type. 674 bugTemplate.CommentTemplate = "{{.BugID.BuganizerBugID}}" 675 676 err := validate(project, cfg) 677 So(err, ShouldErrLike, `(`+path+`): validate template: test case "monorail"`) 678 So(err, ShouldErrLike, `error calling BuganizerBugID: not a buganizer bug`) 679 }) 680 Convey("invalid - does not handle buganizer bug", func() { 681 // Unqualified access of Monorail Bug ID without checking bug type. 682 bugTemplate.CommentTemplate = "{{.BugID.MonorailBugID}}" 683 684 err := validate(project, cfg) 685 So(err, ShouldErrLike, `(`+path+`): validate template: test case "buganizer"`) 686 So(err, ShouldErrLike, `error calling MonorailBugID: not a monorail bug`) 687 }) 688 Convey("invalid - does not handle reserved bug system", func() { 689 // Access of Buganizer Bug ID based on assumption that 690 // absence of monorail Bug ID implies Buganizer, without 691 // considering that the system may be extended in future. 692 bugTemplate.CommentTemplate = "{{if .BugID.IsMonorail}}{{.BugID.MonorailBugID}}{{else}}{{.BugID.BuganizerBugID}}{{end}}" 693 694 err := validate(project, cfg) 695 So(err, ShouldErrLike, `(`+path+`): validate template: test case "neither buganizer nor monorail"`) 696 So(err, ShouldErrLike, `error calling BuganizerBugID: not a buganizer bug`) 697 }) 698 }) 699 Convey("buganizer", func() { 700 path := path + " / buganizer" 701 Convey("may be unset", func() { 702 // Not all policies need to avail themselves of buganizer-specific 703 // features. 704 bugTemplate.Buganizer = nil 705 So(validate(project, cfg), ShouldBeNil) 706 }) 707 buganizer := bugTemplate.Buganizer 708 Convey("hotlists", func() { 709 path := path + " / hotlists" 710 Convey("empty", func() { 711 buganizer.Hotlists = nil 712 So(validate(project, cfg), ShouldBeNil) 713 }) 714 Convey("too many", func() { 715 buganizer.Hotlists = make([]int64, 0, 11) 716 for i := 0; i < 11; i++ { 717 buganizer.Hotlists = append(buganizer.Hotlists, 1) 718 } 719 So(validate(project, cfg), ShouldErrLike, `(`+path+`): exceeds maximum of 5 hotlists`) 720 }) 721 Convey("duplicate IDs", func() { 722 buganizer.Hotlists = []int64{1, 1} 723 So(validate(project, cfg), ShouldErrLike, `(`+path+` / [1]): ID 1 appears in collection more than once`) 724 }) 725 Convey("invalid - non-positive ID", func() { 726 buganizer.Hotlists[0] = 0 727 So(validate(project, cfg), ShouldErrLike, `(`+path+` / [0]): ID must be positive`) 728 }) 729 }) 730 }) 731 Convey("monorail", func() { 732 path := path + " / monorail" 733 Convey("may be unset", func() { 734 bugTemplate.Monorail = nil 735 So(validate(project, cfg), ShouldBeNil) 736 }) 737 monorail := bugTemplate.Monorail 738 Convey("labels", func() { 739 path := path + " / labels" 740 Convey("empty", func() { 741 monorail.Labels = nil 742 So(validate(project, cfg), ShouldBeNil) 743 }) 744 Convey("too many", func() { 745 monorail.Labels = make([]string, 0, 11) 746 for i := 0; i < 11; i++ { 747 monorail.Labels = append(monorail.Labels, fmt.Sprintf("label-%v", i)) 748 } 749 So(validate(project, cfg), ShouldErrLike, `(`+path+`): exceeds maximum of 5 labels`) 750 }) 751 Convey("duplicate labels", func() { 752 monorail.Labels = []string{"a", "A"} 753 So(validate(project, cfg), ShouldErrLike, `(`+path+` / [1]): label "a" appears in collection more than once`) 754 }) 755 Convey("invalid - empty label", func() { 756 monorail.Labels[0] = "" 757 So(validate(project, cfg), ShouldErrLike, `(`+path+` / [0]): must be specified`) 758 }) 759 Convey("invalid - bad label", func() { 760 monorail.Labels[0] = "!test" 761 So(validate(project, cfg), ShouldErrLike, `(`+path+` / [0]): does not match pattern "^[a-zA-Z0-9\\-]+$"`) 762 }) 763 Convey("invalid - too long label", func() { 764 monorail.Labels[0] = strings.Repeat("a", 61) 765 So(validate(project, cfg), ShouldErrLike, `(`+path+` / [0]): exceeds maximum allowed length of 60 bytes`) 766 }) 767 }) 768 }) 769 }) 770 }) 771 }) 772 Convey("test stability criteria", t, func() { 773 cfg := CreateConfigWithBothBuganizerAndMonorail(configpb.BugSystem_BUGANIZER) 774 775 path := "test_stability_criteria" 776 tsc := cfg.TestStabilityCriteria 777 778 Convey("may be left unset", func() { 779 cfg.TestStabilityCriteria = nil 780 So(validate(project, cfg), ShouldBeNil) 781 }) 782 Convey("failure rate", func() { 783 path := path + " / failure_rate" 784 fr := tsc.FailureRate 785 Convey("unset", func() { 786 tsc.FailureRate = nil 787 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 788 }) 789 Convey("consecutive failure threshold", func() { 790 path := path + " / consecutive_failure_threshold" 791 Convey("unset", func() { 792 fr.ConsecutiveFailureThreshold = 0 793 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 794 }) 795 Convey("invalid - more than ten", func() { 796 fr.ConsecutiveFailureThreshold = 11 797 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be in the range [1, 10]`) 798 }) 799 Convey("invalid - less than zero", func() { 800 fr.ConsecutiveFailureThreshold = -1 801 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be in the range [1, 10]`) 802 }) 803 }) 804 Convey("failure threshold", func() { 805 path := path + " / failure_threshold" 806 Convey("unset", func() { 807 fr.FailureThreshold = 0 808 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 809 }) 810 Convey("invalid - more than ten", func() { 811 fr.FailureThreshold = 11 812 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be in the range [1, 10]`) 813 }) 814 Convey("invalid - less than zero", func() { 815 fr.FailureThreshold = -1 816 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be in the range [1, 10]`) 817 }) 818 }) 819 }) 820 Convey("flake rate", func() { 821 path := path + " / flake_rate" 822 fr := tsc.FlakeRate 823 Convey("unset", func() { 824 tsc.FlakeRate = nil 825 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 826 }) 827 Convey("min window", func() { 828 path := path + " / min_window" 829 Convey("may be unset", func() { 830 fr.MinWindow = 0 831 So(validate(project, cfg), ShouldBeNil) 832 }) 833 Convey("invalid - too large", func() { 834 fr.MinWindow = 1_000_001 835 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be in the range [0, 1000000]`) 836 }) 837 Convey("invalid - less than zero", func() { 838 fr.MinWindow = -1 839 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be in the range [0, 1000000]`) 840 }) 841 }) 842 Convey("flake threshold", func() { 843 path := path + " / flake_threshold" 844 Convey("unset", func() { 845 fr.FlakeThreshold = 0 846 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be specified`) 847 }) 848 Convey("invalid - too large", func() { 849 fr.FlakeThreshold = 1_000_001 850 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be in the range [1, 1000000]`) 851 }) 852 Convey("invalid - less than zero", func() { 853 fr.FlakeThreshold = -1 854 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be in the range [1, 1000000]`) 855 }) 856 }) 857 Convey("flake rate threshold", func() { 858 path := path + " / flake_rate_threshold" 859 Convey("may be unset", func() { 860 fr.FlakeRateThreshold = 0 861 So(validate(project, cfg), ShouldBeNil) 862 }) 863 Convey("invalid - NaN", func() { 864 fr.FlakeRateThreshold = math.NaN() 865 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be a finite number`) 866 }) 867 Convey("invalid - infinity", func() { 868 fr.FlakeRateThreshold = math.Inf(1) 869 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be a finite number`) 870 }) 871 Convey("invalid - too large", func() { 872 fr.FlakeRateThreshold = 1.0001 873 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be in the range [0.000000, 1.000000]`) 874 }) 875 Convey("invalid - less than zero", func() { 876 fr.FlakeRateThreshold = -0.0001 877 So(validate(project, cfg), ShouldErrLike, `(`+path+`): must be in the range [0.000000, 1.000000]`) 878 }) 879 }) 880 }) 881 }) 882 }