diff --git a/.gitignore b/.gitignore index 43d732d..1023ed2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ .DS_Store dist/ + +*.out \ No newline at end of file diff --git a/README.MD b/README.MD index a39f05b..43e1ebc 100644 --- a/README.MD +++ b/README.MD @@ -1 +1,28 @@ # Sites.yml | Pantheon.yml Validator + +A utility for validating a sites.yml file on a pantheon site during WordPress multisites' search-replace tasks. Asprirationally to include pantheon.yml validation in the future. + +# Usage + +## Sites.yml +``` +$ pyml-validator sites -f path/to/sites.yml +``` + +See [this annotated fixture](./fixtures/sites/valid.yml) for an example of a valid sites.yml file. + +## Pantheon.yml +Note, validation of pantheon.yml is unimplemented, so any file reads as valid. +``` +$ pyml-validator pantheon -f path/to/pantheon.yml +``` + +# Testing + +[![Coverage Status](https://coveralls.io/repos/github/pantheon-systems/pyml-validator/badge.svg?t=PGhafd)](https://coveralls.io/github/pantheon-systems/pyml-validator) + +`make test` runs linting and testing. + +# Releases + +Automatically releases on merge to main via autotag + goreleaser. See [Autotag Readme](https://github.com/pantheon-systems/autotag) for details on how the SemVer is determined. Note, with goreleaser, each commit merged will become a line item in the release's Changelog. Take note to use squashing and/or rebase to ensure helpful and informative commit messages. diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..6cd603f --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "log" + + "github.com/spf13/cobra" +) + +var FilePath string + +var rootCmd = &cobra.Command{ + Use: "pyml-validator", + Short: "Pyml-validator validates pantheon.yml, sites.yml, etc.", + Long: `Pyml-validator is a validator for pantheon.yml or sites.yml. +Ensures that the given config file can be used by the platform.`, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + log.Fatal(err) + } +} + +func init() { + rootCmd.PersistentFlags().StringVarP(&FilePath, "file", "f", "", "path/to/file.yml") + err := rootCmd.MarkPersistentFlagRequired("file") + if err != nil { + log.Fatal(err) + } +} diff --git a/cmd/validators.go b/cmd/validators.go new file mode 100644 index 0000000..23832ed --- /dev/null +++ b/cmd/validators.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "fmt" + "pyml-validator/pkg/validator" + + "github.com/spf13/cobra" +) + +func validatorCommand(cmd *cobra.Command) error { + // Is there a better way to do this? Without this we print usage on error exits. + // If we override at the root level, we don't get usage when we _do_ want it. + cmd.SilenceUsage = true + + v, err := validator.ValidatorFactory(cmd.Use) + if err != nil { + return err + } + + err = v.ValidateFromFilePath(FilePath) + if err != nil { + return err + } + fmt.Printf("✨ %s.yml is valid\n", cmd.Use) + return nil +} + +var sitesCommand = &cobra.Command{ + Use: "sites", + Short: "validate sites.yml", + Long: `Validate sites.yml`, + RunE: func(cmd *cobra.Command, args []string) error { + return validatorCommand(cmd) + }, +} + +var pantheonCommand = &cobra.Command{ + Use: "pantheon", + Short: "validate pantheon.yml", + Long: `Validate pantheon.yml. For more information, see https://pantheon.io/docs/pantheon-yml`, + RunE: func(cmd *cobra.Command, args []string) error { + return validatorCommand(cmd) + }, +} + +func init() { + rootCmd.AddCommand(pantheonCommand) + rootCmd.AddCommand(sitesCommand) +} diff --git a/fixtures/sites/invalid_api_version_only.yml b/fixtures/sites/invalid_api_version_only.yml new file mode 100644 index 0000000..f56636c --- /dev/null +++ b/fixtures/sites/invalid_api_version_only.yml @@ -0,0 +1,2 @@ +--- +api_version: 2 \ No newline at end of file diff --git a/fixtures/sites/valid.yml b/fixtures/sites/valid.yml new file mode 100644 index 0000000..e0fa0d4 --- /dev/null +++ b/fixtures/sites/valid.yml @@ -0,0 +1,31 @@ +--- +api_version: 1 # Currently only one api version. + +# "domain_maps" is a collection of blog URLs for each environment used to +# facilitate search-replace of a WordPress Multisite (WPMS) across pantheon +# environments. Each key of "domain_maps" must be a valid environment name. +domain_maps: + # environment: + # i.e. dev, test, live, feat-branch, &c. + dev: + # each environment collection maps the blog ID to its URL. A url must be + # set in both the target and source environments for search-replace to be + # run. + # i.e. 1: blog1-mysite.com + 1: about.dev-mysite.pantheonsite.io + 2: employee-resources.dev-mysite.pantheonsite.io + 3: staff-portal.dev-mysite.pantheonsite.io + test: + 1: about.test-mysite.pantheonsite.io + 2: employee-resources.test-mysite.pantheonsite.io + 3: staff-portal.test-mysite.pantheonsite.io + live: + 1: about.mysite.com + 2: employee-resources.mysite.com + 3: staff-portal.mysite.com + autopilot: + 1: about.autopilot-mysite.pantheonsite.io + 2: employee-resources.autopilot-mysite.pantheonsite.io + 3: staff-portal.autopilot-mysite.pantheonsite.io + +# Anything else in the file will be ignored, but not rejected. \ No newline at end of file diff --git a/fixtures/sites/valid_api_version_only.yml b/fixtures/sites/valid_api_version_only.yml new file mode 100644 index 0000000..da0609b --- /dev/null +++ b/fixtures/sites/valid_api_version_only.yml @@ -0,0 +1,2 @@ +--- +api_version: 1 \ No newline at end of file diff --git a/go.mod b/go.mod index e23728c..873d994 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,18 @@ module pyml-validator go 1.19 + +require ( + github.com/spf13/cobra v1.6.1 + github.com/stretchr/testify v1.8.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) + +replace gopkg.in/yaml.v3 => gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c7e9ea7 --- /dev/null +++ b/go.sum @@ -0,0 +1,25 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= +github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 38dd16d..68348e0 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,7 @@ package main -func main() {} +import "pyml-validator/cmd" + +func main() { + cmd.Execute() +} diff --git a/pkg/model/sites.go b/pkg/model/sites.go new file mode 100644 index 0000000..01856c4 --- /dev/null +++ b/pkg/model/sites.go @@ -0,0 +1,12 @@ +package model + +// SitesYml is used to map domains across environments for search and replace with WPMS sites. +type SitesYml struct { + APIVersion int `yaml:"api_version"` + DomainMaps DomainMaps `yaml:"domain_maps"` +} + +type DomainMaps map[string]DomainMapByEnvironment + +// DomainMapByEnvironment is a map of site (blog) domains keyed by blog ID. +type DomainMapByEnvironment map[int]string diff --git a/pkg/validator/pantheon.go b/pkg/validator/pantheon.go new file mode 100644 index 0000000..1ef104d --- /dev/null +++ b/pkg/validator/pantheon.go @@ -0,0 +1,22 @@ +package validator + +import ( + "fmt" + "os" +) + +type PantheonValidator struct{} + +// ValidateFromYaml asserts a given pantheon.yaml file is valid. +// As this has not been implemented, nothing is invalid. +func (v *PantheonValidator) ValidateFromYaml(y []byte) error { + return nil +} + +func (v *PantheonValidator) ValidateFromFilePath(filePath string) error { + yFile, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("error reading YAML file: %w", err) + } + return v.ValidateFromYaml(yFile) +} diff --git a/pkg/validator/sites.go b/pkg/validator/sites.go new file mode 100644 index 0000000..c2128ad --- /dev/null +++ b/pkg/validator/sites.go @@ -0,0 +1,81 @@ +package validator + +import ( + "fmt" + "os" + "pyml-validator/pkg/model" + "regexp" + + "gopkg.in/yaml.v3" +) + +const ( + MaxDomainMaps = 25 // This could be raised +) + +var ( + // See https://github.com/pantheon-systems/titan-mt/blob/master/yggdrasil/lib/pantheon_yml/pantheon_yml_v1_schema.py + ValidHostnameRegex = regexp.MustCompile(`^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$`) + ValidMultidevNameRegex = regexp.MustCompile(`^[a-z0-9\-]{1,11}$`) +) + +type SitesValidator struct{} + +// ValidateFromYaml asserts a given sites.yaml file is valid. +func (v *SitesValidator) ValidateFromYaml(y []byte) error { + var s model.SitesYml + + err := yaml.Unmarshal(y, &s) + if err != nil { + return err + } + return v.validate(s) +} + +func (v *SitesValidator) ValidateFromFilePath(filePath string) error { + yFile, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("error reading YAML file: %w", err) + } + return v.ValidateFromYaml(yFile) +} + +// validate asserts all aspects of sites.yml are valid. +func (v *SitesValidator) validate(sites model.SitesYml) error { + err := validateAPIVersion(sites.APIVersion) + if err != nil { + return err + } + return validateDomainMaps(sites.DomainMaps) +} + +// validateDomainMaps ensures the domain maps provided in sites.yml are valid +// by asserting cloud development environments names are valid, there are not +// too many domain maps listed for any environment, and that the hostnames +// provided are valid Pantheon hostnames. +func validateDomainMaps(domainMaps map[string]model.DomainMapByEnvironment) error { + for env, domainMap := range domainMaps { + if !ValidMultidevNameRegex.MatchString(env) { + return fmt.Errorf("%q is not a valid environment name", env) + } + domainMapCount := len(domainMap) + if domainMapCount > MaxDomainMaps { + return fmt.Errorf("%q has too many domains listed (%d). Maximum is %d", env, domainMapCount, MaxDomainMaps) + } + for _, domain := range domainMap { + if !ValidHostnameRegex.MatchString(domain) { + return fmt.Errorf("%q is not a valid hostname", domain) + } + } + } + return nil +} + +// validateAPIVersion asserts if sites.yml has a valid api version set. Once +// more than one version is valid, this will need to be more more robust. +func validateAPIVersion(apiVersion int) error { + if apiVersion != 1 { + return ErrInvalidAPIVersion + } + return nil +} diff --git a/pkg/validator/sites_errors.go b/pkg/validator/sites_errors.go new file mode 100644 index 0000000..682b7a5 --- /dev/null +++ b/pkg/validator/sites_errors.go @@ -0,0 +1,10 @@ +package validator + +import "errors" + +var ( + ErrInvalidAPIVersion = errors.New("Invalid API Version. Must be '1'") +) + +// TODO: More dynamic errors could be refactored here, but likely only worth +// pursuing once we are passing errors back to customers diff --git a/pkg/validator/sites_test.go b/pkg/validator/sites_test.go new file mode 100644 index 0000000..40b8ea9 --- /dev/null +++ b/pkg/validator/sites_test.go @@ -0,0 +1,250 @@ +package validator + +import ( + "errors" + "fmt" + "pyml-validator/pkg/model" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestValidateAPIVersion(t *testing.T) { + for _, tc := range []struct { + version int + expected error + }{ + {1, nil}, + {2, ErrInvalidAPIVersion}, + } { + t.Run(fmt.Sprintf("%v", tc.version), func(t *testing.T) { + err := validateAPIVersion(tc.version) + assert.ErrorIs(t, err, tc.expected) + }) + } +} + +func TestValidate(t *testing.T) { + for _, tc := range []struct { + name string + sitesYml model.SitesYml + expected error + }{ + { + "valid only api version", + model.SitesYml{APIVersion: 1}, + nil, + }, + { + "invalid api version", + model.SitesYml{APIVersion: 2}, + ErrInvalidAPIVersion, + }, + { + "valid domain maps", + model.SitesYml{ + APIVersion: 1, + DomainMaps: model.DomainMaps{ + "dev": model.DomainMapByEnvironment{ + 1: "blog1.dev-mysite.pantheonsite.io", + }, + "test": model.DomainMapByEnvironment{ + 1: "blog1.dev-mysite.pantheonsite.io", + }, + "live": model.DomainMapByEnvironment{ + 1: "blog1.mysite.com", + }, + "autopilot": model.DomainMapByEnvironment{ + 1: "blog1.autopilot-mysite.pantheonsite.io", + }, + }, + }, + nil, + }, + { + "invalid domain maps long env", + model.SitesYml{ + APIVersion: 1, + DomainMaps: model.DomainMaps{ + "dev": model.DomainMapByEnvironment{ + 1: "blog1.dev-mysite.pantheonsite.io", + }, + "mylongmultidevname": model.DomainMapByEnvironment{ + 1: "blog1.mylongmultidevname-mysite.pantheonsite.io", + }, + }, + }, + errors.New(`"mylongmultidevname" is not a valid environment name`), + }, + { + "invalid domain maps bad env name", + model.SitesYml{ + APIVersion: 1, + DomainMaps: model.DomainMaps{ + "dev": model.DomainMapByEnvironment{ + 1: "blog1.dev-mysite.pantheonsite.io", + }, + "feat_branch": model.DomainMapByEnvironment{ + 1: "blog1.feat-branch-mysite.pantheonsite.io", + }, + }, + }, + errors.New(`"feat_branch" is not a valid environment name`), + }, + { + "invalid domain maps too many domains", + model.SitesYml{ + APIVersion: 1, + DomainMaps: model.DomainMaps{ + "dev": model.DomainMapByEnvironment{ + 1: "blog1.dev-mysite.pantheonsite.io", + 2: "blog2.dev-mysite.pantheonsite.io", + 3: "blog3.dev-mysite.pantheonsite.io", + 4: "blog4.dev-mysite.pantheonsite.io", + 5: "blog5.dev-mysite.pantheonsite.io", + 6: "blog6.dev-mysite.pantheonsite.io", + 7: "blog7.dev-mysite.pantheonsite.io", + 8: "blog8.dev-mysite.pantheonsite.io", + 9: "blog9.dev-mysite.pantheonsite.io", + 10: "blog10.dev-mysite.pantheonsite.io", + 11: "blog11.dev-mysite.pantheonsite.io", + 12: "blog12.dev-mysite.pantheonsite.io", + 13: "blog13.dev-mysite.pantheonsite.io", + 14: "blog14.dev-mysite.pantheonsite.io", + 15: "blog15.dev-mysite.pantheonsite.io", + 16: "blog16.dev-mysite.pantheonsite.io", + 17: "blog17.dev-mysite.pantheonsite.io", + 18: "blog18.dev-mysite.pantheonsite.io", + 19: "blog19.dev-mysite.pantheonsite.io", + 20: "blog20.dev-mysite.pantheonsite.io", + 21: "blog21.dev-mysite.pantheonsite.io", + 22: "blog22.dev-mysite.pantheonsite.io", + 23: "blog23.dev-mysite.pantheonsite.io", + 24: "blog24.dev-mysite.pantheonsite.io", + 25: "blog25.dev-mysite.pantheonsite.io", + 26: "blog26.dev-mysite.pantheonsite.io", + 27: "blog27.dev-mysite.pantheonsite.io", + 28: "blog28.dev-mysite.pantheonsite.io", + 29: "blog29.dev-mysite.pantheonsite.io", + }, + "feat_branch": model.DomainMapByEnvironment{ + 1: "blog1.feat-branch-mysite.pantheonsite.io", + }, + }, + }, + errors.New(`"dev" has too many domains listed (29). Maximum is 25`), + }, + { + "invalid hostname", + model.SitesYml{ + APIVersion: 1, + DomainMaps: model.DomainMaps{ + "dev": model.DomainMapByEnvironment{ + 1: "blog1.dev-mysite.pantheonsite.io", + }, + "test": model.DomainMapByEnvironment{ + 1: "$(sudo do something dangerous)", + }, + "live": model.DomainMapByEnvironment{ + 1: "blog1.mysite.com", + }, + }, + }, + errors.New(`"$(sudo do something dangerous)" is not a valid hostname`), + }, + } { + t.Run(tc.name, func(t *testing.T) { + v, err := ValidatorFactory("sites") + require.NoError(t, err) + err = v.(*SitesValidator).validate(tc.sitesYml) + if tc.expected == nil { + assert.NoError(t, err) + return + } + assert.EqualError(t, err, tc.expected.Error()) + }) + } +} + +func TestValidateFromYaml(t *testing.T) { + for _, tc := range []struct { + name string + yaml string + expected error + }{ + { + name: "only api_version", + yaml: ` + --- + api_version: 1`, + expected: nil, + }, + { + name: "invalid api_version ", + yaml: ` + --- + api_version: 2`, + expected: ErrInvalidAPIVersion, + }, + { + name: "invalid api_version ", + yaml: `this is not good yaml`, + expected: &yaml.TypeError{ + Errors: []string{ + "line 1: cannot unmarshal !!str `this is...` into model.SitesYml", + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + yaml := []byte( + // Yaml doesn't like tabs, but lets us make our test cases prettier + strings.ReplaceAll(tc.yaml, "\t", ""), + ) + + v, err := ValidatorFactory("sites") + require.NoError(t, err) + err = v.ValidateFromYaml(yaml) + if tc.expected == nil { + assert.NoError(t, err) + return + } + // TODO: assert.ErrorIs would be a better test. + assert.EqualError(t, err, tc.expected.Error()) + }) + } +} + +func TestValidateSitesFromFilePath(t *testing.T) { + for _, tc := range []struct { + fixtureName string + expected error + }{ + {"invalid_api_version_only", ErrInvalidAPIVersion}, + {"valid_api_version_only", nil}, + {"valid", nil}, + { + "this_file_does_not_exist", errors.New( + "error reading YAML file: open ../../fixtures/sites/this_file_does_not_exist.yml: no such file or directory", + ), + }, + } { + t.Run(tc.fixtureName, func(t *testing.T) { + v, err := ValidatorFactory("sites") + require.NoError(t, err) + filePath := fmt.Sprintf("../../fixtures/sites/%s.yml", tc.fixtureName) + + err = v.ValidateFromFilePath(filePath) + if tc.expected == nil { + assert.NoError(t, err) + return + } + + // TODO: assert.ErrorIs would be a better test. + assert.EqualError(t, err, tc.expected.Error()) + }) + } +} diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go new file mode 100644 index 0000000..8d1bbce --- /dev/null +++ b/pkg/validator/validator.go @@ -0,0 +1,21 @@ +package validator + +import ( + "fmt" +) + +type Validator interface { + ValidateFromYaml(y []byte) error + ValidateFromFilePath(s string) error +} + +func ValidatorFactory(v string) (Validator, error) { + switch v { + case "sites": + return &SitesValidator{}, nil + case "pantheon": + return &PantheonValidator{}, nil + default: + return nil, fmt.Errorf(`%q is not a valid validator.`, v) + } +} diff --git a/pkg/validator/validator_test.go b/pkg/validator/validator_test.go new file mode 100644 index 0000000..690fac6 --- /dev/null +++ b/pkg/validator/validator_test.go @@ -0,0 +1,30 @@ +package validator + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidatorFactory(t *testing.T) { + for _, tc := range []struct { + name string + expected Validator + expectedErr error + }{ + {"sites", &SitesValidator{}, nil}, + {"pantheon", &PantheonValidator{}, nil}, + {"foo", nil, errors.New(`"foo" is not a valid validator.`)}, + } { + t.Run(tc.name, func(t *testing.T) { + result, err := ValidatorFactory(tc.name) + if tc.expectedErr != nil { + assert.EqualError(t, err, tc.expectedErr.Error()) + return + } + assert.NoError(t, err) + assert.Equal(t, result, tc.expected) + }) + } +}