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  }