Merge pull request #81212 from liggitt/crd-validation

Return CR validation errors as field errors
This commit is contained in:
Kubernetes Prow Robot 2019-08-09 23:57:12 -07:00 committed by GitHub
commit 2c455e0ac0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 221 additions and 73 deletions

View File

@ -7,6 +7,7 @@ go 1.12
require (
github.com/coreos/etcd v3.3.13+incompatible
github.com/emicklei/go-restful v2.9.5+incompatible
github.com/go-openapi/errors v0.19.2
github.com/go-openapi/spec v0.19.2
github.com/go-openapi/strfmt v0.19.0
github.com/go-openapi/validate v0.19.2

View File

@ -820,9 +820,8 @@ func (v *specStandardValidatorV3) validate(schema *apiextensions.JSONSchemaProps
// validate the default value with user the provided schema.
validator := govalidate.NewSchemaValidator(s.ToGoOpenAPI(), nil, "", strfmt.Default)
if err := apiservervalidation.ValidateCustomResource(interface{}(*schema.Default), validator); err != nil {
allErrs = append(allErrs, field.Invalid(fldPath.Child("default"), schema.Default, fmt.Sprintf("must validate: %v", err)))
}
allErrs = append(allErrs, apiservervalidation.ValidateCustomResource(fldPath.Child("default"), interface{}(*schema.Default), validator)...)
}
} else {
detail := "must not be set"

View File

@ -1944,8 +1944,8 @@ func TestValidateCustomResourceDefinition(t *testing.T) {
},
errors: []validationMatch{
invalid("spec", "validation", "openAPIV3Schema", "properties[a]", "default"),
invalid("spec", "validation", "openAPIV3Schema", "properties[c]", "default"),
invalid("spec", "validation", "openAPIV3Schema", "properties[d]", "default"),
invalid("spec", "validation", "openAPIV3Schema", "properties[c]", "default", "foo"),
invalid("spec", "validation", "openAPIV3Schema", "properties[d]", "default", "bad"),
invalid("spec", "validation", "openAPIV3Schema", "properties[d]", "properties[bad]", "pattern"),
// we also expected unpruned and valid defaults under x-kubernetes-preserve-unknown-fields. We could be more
// strict here, but want to encourage proper specifications by forbidding other defaults.

View File

@ -13,6 +13,8 @@ go_library(
importpath = "k8s.io/apiextensions-apiserver/pkg/apiserver/validation",
deps = [
"//staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/util/validation/field:go_default_library",
"//vendor/github.com/go-openapi/errors:go_default_library",
"//vendor/github.com/go-openapi/spec:go_default_library",
"//vendor/github.com/go-openapi/strfmt:go_default_library",
"//vendor/github.com/go-openapi/validate:go_default_library",
@ -45,6 +47,7 @@ go_test(
"//staging/src/k8s.io/apimachinery/pkg/runtime:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/runtime/serializer:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/util/json:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/util/sets:go_default_library",
"//vendor/github.com/go-openapi/spec:go_default_library",
],
)

View File

@ -17,11 +17,16 @@ limitations under the License.
package validation
import (
"encoding/json"
"strings"
openapierrors "github.com/go-openapi/errors"
"github.com/go-openapi/spec"
"github.com/go-openapi/strfmt"
"github.com/go-openapi/validate"
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
"k8s.io/apimachinery/pkg/util/validation/field"
)
// NewSchemaValidator creates an openapi schema validator for the given CRD validation.
@ -39,16 +44,50 @@ func NewSchemaValidator(customResourceValidation *apiextensions.CustomResourceVa
// ValidateCustomResource validates the Custom Resource against the schema in the CustomResourceDefinition.
// CustomResource is a JSON data structure.
func ValidateCustomResource(customResource interface{}, validator *validate.SchemaValidator) error {
func ValidateCustomResource(fldPath *field.Path, customResource interface{}, validator *validate.SchemaValidator) field.ErrorList {
if validator == nil {
return nil
}
result := validator.Validate(customResource)
if result.AsError() != nil {
return result.AsError()
if result.IsValid() {
return nil
}
return nil
var allErrs field.ErrorList
for _, err := range result.Errors {
switch err := err.(type) {
case *openapierrors.Validation:
switch err.Code() {
case openapierrors.RequiredFailCode:
allErrs = append(allErrs, field.Required(fldPath.Child(strings.TrimPrefix(err.Name, ".")), ""))
case openapierrors.EnumFailCode:
values := []string{}
for _, allowedValue := range err.Values {
if s, ok := allowedValue.(string); ok {
values = append(values, s)
} else {
allowedJSON, _ := json.Marshal(allowedValue)
values = append(values, string(allowedJSON))
}
}
allErrs = append(allErrs, field.NotSupported(fldPath.Child(strings.TrimPrefix(err.Name, ".")), err.Value, values))
default:
value := interface{}("")
if err.Value != nil {
value = err.Value
}
allErrs = append(allErrs, field.Invalid(fldPath.Child(strings.TrimPrefix(err.Name, ".")), value, err.Error()))
}
default:
allErrs = append(allErrs, field.Invalid(fldPath, "", err.Error()))
}
}
return allErrs
}
// ConvertJSONSchemaProps converts the schema from apiextensions.JSONSchemaPropos to go-openapi/spec.Schema.

View File

@ -21,6 +21,7 @@ import (
"testing"
"github.com/go-openapi/spec"
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
apiextensionsfuzzer "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/fuzzer"
apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
@ -29,6 +30,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/util/json"
"k8s.io/apimachinery/pkg/util/sets"
)
// TestRoundTrip checks the conversion to go-openapi types.
@ -121,12 +123,17 @@ func stripIntOrStringType(x interface{}) interface{} {
}
}
type failingObject struct {
object interface{}
expectErrs []string
}
func TestValidateCustomResource(t *testing.T) {
tests := []struct {
name string
schema apiextensions.JSONSchemaProps
objects []interface{}
failingObjects []interface{}
failingObjects []failingObject
}{
{name: "!nullable",
schema: apiextensions.JSONSchemaProps{
@ -141,12 +148,13 @@ func TestValidateCustomResource(t *testing.T) {
map[string]interface{}{},
map[string]interface{}{"field": map[string]interface{}{}},
},
failingObjects: []interface{}{
map[string]interface{}{"field": "foo"},
map[string]interface{}{"field": 42},
map[string]interface{}{"field": true},
map[string]interface{}{"field": 1.2},
map[string]interface{}{"field": []interface{}{}},
failingObjects: []failingObject{
{object: map[string]interface{}{"field": "foo"}, expectErrs: []string{`field: Invalid value: "string": field in body must be of type object: "string"`}},
{object: map[string]interface{}{"field": 42}, expectErrs: []string{`field: Invalid value: "integer": field in body must be of type object: "integer"`}},
{object: map[string]interface{}{"field": true}, expectErrs: []string{`field: Invalid value: "boolean": field in body must be of type object: "boolean"`}},
{object: map[string]interface{}{"field": 1.2}, expectErrs: []string{`field: Invalid value: "number": field in body must be of type object: "number"`}},
{object: map[string]interface{}{"field": []interface{}{}}, expectErrs: []string{`field: Invalid value: "array": field in body must be of type object: "array"`}},
{object: map[string]interface{}{"field": nil}, expectErrs: []string{`field: Invalid value: "null": field in body must be of type object: "null"`}},
},
},
{name: "nullable",
@ -163,12 +171,12 @@ func TestValidateCustomResource(t *testing.T) {
map[string]interface{}{"field": map[string]interface{}{}},
map[string]interface{}{"field": nil},
},
failingObjects: []interface{}{
map[string]interface{}{"field": "foo"},
map[string]interface{}{"field": 42},
map[string]interface{}{"field": true},
map[string]interface{}{"field": 1.2},
map[string]interface{}{"field": []interface{}{}},
failingObjects: []failingObject{
{object: map[string]interface{}{"field": "foo"}, expectErrs: []string{`field: Invalid value: "string": field in body must be of type object: "string"`}},
{object: map[string]interface{}{"field": 42}, expectErrs: []string{`field: Invalid value: "integer": field in body must be of type object: "integer"`}},
{object: map[string]interface{}{"field": true}, expectErrs: []string{`field: Invalid value: "boolean": field in body must be of type object: "boolean"`}},
{object: map[string]interface{}{"field": 1.2}, expectErrs: []string{`field: Invalid value: "number": field in body must be of type object: "number"`}},
{object: map[string]interface{}{"field": []interface{}{}}, expectErrs: []string{`field: Invalid value: "array": field in body must be of type object: "array"`}},
},
},
{name: "nullable and no type",
@ -203,12 +211,12 @@ func TestValidateCustomResource(t *testing.T) {
map[string]interface{}{"field": 42},
map[string]interface{}{"field": "foo"},
},
failingObjects: []interface{}{
map[string]interface{}{"field": nil},
map[string]interface{}{"field": true},
map[string]interface{}{"field": 1.2},
map[string]interface{}{"field": map[string]interface{}{}},
map[string]interface{}{"field": []interface{}{}},
failingObjects: []failingObject{
{object: map[string]interface{}{"field": nil}, expectErrs: []string{`field: Invalid value: "null": field in body must be of type integer,string: "null"`}},
{object: map[string]interface{}{"field": true}, expectErrs: []string{`field: Invalid value: "boolean": field in body must be of type integer,string: "boolean"`}},
{object: map[string]interface{}{"field": 1.2}, expectErrs: []string{`field: Invalid value: "number": field in body must be of type integer,string: "number"`}},
{object: map[string]interface{}{"field": map[string]interface{}{}}, expectErrs: []string{`field: Invalid value: "object": field in body must be of type integer,string: "object"`}},
{object: map[string]interface{}{"field": []interface{}{}}, expectErrs: []string{`field: Invalid value: "array": field in body must be of type integer,string: "array"`}},
},
},
{name: "nullable and x-kubernetes-int-or-string",
@ -226,11 +234,11 @@ func TestValidateCustomResource(t *testing.T) {
map[string]interface{}{"field": "foo"},
map[string]interface{}{"field": nil},
},
failingObjects: []interface{}{
map[string]interface{}{"field": true},
map[string]interface{}{"field": 1.2},
map[string]interface{}{"field": map[string]interface{}{}},
map[string]interface{}{"field": []interface{}{}},
failingObjects: []failingObject{
{object: map[string]interface{}{"field": true}, expectErrs: []string{`field: Invalid value: "boolean": field in body must be of type integer,string: "boolean"`}},
{object: map[string]interface{}{"field": 1.2}, expectErrs: []string{`field: Invalid value: "number": field in body must be of type integer,string: "number"`}},
{object: map[string]interface{}{"field": map[string]interface{}{}}, expectErrs: []string{`field: Invalid value: "object": field in body must be of type integer,string: "object"`}},
{object: map[string]interface{}{"field": []interface{}{}}, expectErrs: []string{`field: Invalid value: "array": field in body must be of type integer,string: "array"`}},
},
},
{name: "nullable, x-kubernetes-int-or-string and user-provided anyOf",
@ -252,11 +260,27 @@ func TestValidateCustomResource(t *testing.T) {
map[string]interface{}{"field": 42},
map[string]interface{}{"field": "foo"},
},
failingObjects: []interface{}{
map[string]interface{}{"field": true},
map[string]interface{}{"field": 1.2},
map[string]interface{}{"field": map[string]interface{}{}},
map[string]interface{}{"field": []interface{}{}},
failingObjects: []failingObject{
{object: map[string]interface{}{"field": true}, expectErrs: []string{
`: Invalid value: "": "field" must validate at least one schema (anyOf)`,
`field: Invalid value: "boolean": field in body must be of type integer,string: "boolean"`,
`field: Invalid value: "boolean": field in body must be of type integer: "boolean"`,
}},
{object: map[string]interface{}{"field": 1.2}, expectErrs: []string{
`: Invalid value: "": "field" must validate at least one schema (anyOf)`,
`field: Invalid value: "number": field in body must be of type integer,string: "number"`,
`field: Invalid value: "number": field in body must be of type integer: "number"`,
}},
{object: map[string]interface{}{"field": map[string]interface{}{}}, expectErrs: []string{
`: Invalid value: "": "field" must validate at least one schema (anyOf)`,
`field: Invalid value: "object": field in body must be of type integer,string: "object"`,
`field: Invalid value: "object": field in body must be of type integer: "object"`,
}},
{object: map[string]interface{}{"field": []interface{}{}}, expectErrs: []string{
`: Invalid value: "": "field" must validate at least one schema (anyOf)`,
`field: Invalid value: "array": field in body must be of type integer,string: "array"`,
`field: Invalid value: "array": field in body must be of type integer: "array"`,
}},
},
},
{name: "nullable, x-kubernetes-int-or-string and user-provider allOf",
@ -282,11 +306,31 @@ func TestValidateCustomResource(t *testing.T) {
map[string]interface{}{"field": 42},
map[string]interface{}{"field": "foo"},
},
failingObjects: []interface{}{
map[string]interface{}{"field": true},
map[string]interface{}{"field": 1.2},
map[string]interface{}{"field": map[string]interface{}{}},
map[string]interface{}{"field": []interface{}{}},
failingObjects: []failingObject{
{object: map[string]interface{}{"field": true}, expectErrs: []string{
`: Invalid value: "": "field" must validate all the schemas (allOf). None validated`,
`: Invalid value: "": "field" must validate at least one schema (anyOf)`,
`field: Invalid value: "boolean": field in body must be of type integer,string: "boolean"`,
`field: Invalid value: "boolean": field in body must be of type integer: "boolean"`,
}},
{object: map[string]interface{}{"field": 1.2}, expectErrs: []string{
`: Invalid value: "": "field" must validate all the schemas (allOf). None validated`,
`: Invalid value: "": "field" must validate at least one schema (anyOf)`,
`field: Invalid value: "number": field in body must be of type integer,string: "number"`,
`field: Invalid value: "number": field in body must be of type integer: "number"`,
}},
{object: map[string]interface{}{"field": map[string]interface{}{}}, expectErrs: []string{
`: Invalid value: "": "field" must validate all the schemas (allOf). None validated`,
`: Invalid value: "": "field" must validate at least one schema (anyOf)`,
`field: Invalid value: "object": field in body must be of type integer,string: "object"`,
`field: Invalid value: "object": field in body must be of type integer: "object"`,
}},
{object: map[string]interface{}{"field": []interface{}{}}, expectErrs: []string{
`: Invalid value: "": "field" must validate all the schemas (allOf). None validated`,
`: Invalid value: "": "field" must validate at least one schema (anyOf)`,
`field: Invalid value: "array": field in body must be of type integer,string: "array"`,
`field: Invalid value: "array": field in body must be of type integer: "array"`,
}},
},
},
{name: "invalid regex",
@ -298,7 +342,59 @@ func TestValidateCustomResource(t *testing.T) {
},
},
},
failingObjects: []interface{}{map[string]interface{}{"field": "foo"}},
failingObjects: []failingObject{
{object: map[string]interface{}{"field": "foo"}, expectErrs: []string{"field: Invalid value: \"\": field in body should match '+, but pattern is invalid: error parsing regexp: missing argument to repetition operator: `+`'"}},
},
},
{name: "required field",
schema: apiextensions.JSONSchemaProps{
Required: []string{"field"},
Properties: map[string]apiextensions.JSONSchemaProps{
"field": {
Type: "object",
Required: []string{"nested"},
Properties: map[string]apiextensions.JSONSchemaProps{
"nested": {},
},
},
},
},
failingObjects: []failingObject{
{object: map[string]interface{}{"test": "a"}, expectErrs: []string{`field: Required value`}},
{object: map[string]interface{}{"field": map[string]interface{}{}}, expectErrs: []string{`field.nested: Required value`}},
},
},
{name: "enum",
schema: apiextensions.JSONSchemaProps{
Properties: map[string]apiextensions.JSONSchemaProps{
"field": {
Type: "object",
Required: []string{"nestedint", "nestedstring"},
Properties: map[string]apiextensions.JSONSchemaProps{
"nestedint": {
Type: "integer",
Enum: []apiextensions.JSON{1, 2},
},
"nestedstring": {
Type: "string",
Enum: []apiextensions.JSON{"a", "b"},
},
},
},
},
},
failingObjects: []failingObject{
{object: map[string]interface{}{"field": map[string]interface{}{}}, expectErrs: []string{
`field.nestedint: Required value`,
`field.nestedstring: Required value`,
}},
{object: map[string]interface{}{"field": map[string]interface{}{"nestedint": "x", "nestedstring": true}}, expectErrs: []string{
`field.nestedint: Invalid value: "string": field.nestedint in body must be of type integer: "string"`,
`field.nestedint: Unsupported value: "x": supported values: "1", "2"`,
`field.nestedstring: Invalid value: "boolean": field.nestedstring in body must be of type string: "boolean"`,
`field.nestedstring: Unsupported value: true: supported values: "a", "b"`,
}},
},
},
}
for _, tt := range tests {
@ -308,13 +404,25 @@ func TestValidateCustomResource(t *testing.T) {
t.Fatal(err)
}
for _, obj := range tt.objects {
if err := ValidateCustomResource(obj, validator); err != nil {
t.Errorf("unexpected validation error for %v: %v", obj, err)
if errs := ValidateCustomResource(nil, obj, validator); len(errs) > 0 {
t.Errorf("unexpected validation error for %v: %v", obj, errs)
}
}
for _, obj := range tt.failingObjects {
if err := ValidateCustomResource(obj, validator); err == nil {
t.Errorf("missing error for %v", obj)
for i, failingObject := range tt.failingObjects {
if errs := ValidateCustomResource(nil, failingObject.object, validator); len(errs) == 0 {
t.Errorf("missing error for %v", failingObject.object)
} else {
sawErrors := sets.NewString()
for _, err := range errs {
sawErrors.Insert(err.Error())
}
expectErrs := sets.NewString(failingObject.expectErrs...)
for _, unexpectedError := range sawErrors.Difference(expectErrs).List() {
t.Errorf("%d: unexpected error: %s", i, unexpectedError)
}
for _, missingError := range expectErrs.Difference(sawErrors).List() {
t.Errorf("%d: missing error: %s", i, missingError)
}
}
}
})
@ -367,11 +475,11 @@ func TestItemsProperty(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := ValidateCustomResource(tt.args.object, validator); (err != nil) != tt.wantErr {
if err == nil {
if errs := ValidateCustomResource(nil, tt.args.object, validator); (len(errs) > 0) != tt.wantErr {
if len(errs) == 0 {
t.Error("expected error, but didn't get one")
} else {
t.Errorf("unexpected validation error: %v", err)
t.Errorf("unexpected validation error: %v", errs)
}
}
})

View File

@ -59,9 +59,7 @@ func (a customResourceValidator) Validate(ctx context.Context, obj runtime.Objec
var allErrs field.ErrorList
allErrs = append(allErrs, validation.ValidateObjectMetaAccessor(accessor, a.namespaceScoped, validation.NameIsDNSSubdomain, field.NewPath("metadata"))...)
if err = apiservervalidation.ValidateCustomResource(u.UnstructuredContent(), a.schemaValidator); err != nil {
allErrs = append(allErrs, field.Invalid(field.NewPath(""), u.UnstructuredContent(), err.Error()))
}
allErrs = append(allErrs, apiservervalidation.ValidateCustomResource(nil, u.UnstructuredContent(), a.schemaValidator)...)
allErrs = append(allErrs, a.ValidateScaleSpec(ctx, u, scale)...)
allErrs = append(allErrs, a.ValidateScaleStatus(ctx, u, scale)...)
@ -89,9 +87,7 @@ func (a customResourceValidator) ValidateUpdate(ctx context.Context, obj, old ru
var allErrs field.ErrorList
allErrs = append(allErrs, validation.ValidateObjectMetaAccessorUpdate(objAccessor, oldAccessor, field.NewPath("metadata"))...)
if err = apiservervalidation.ValidateCustomResource(u.UnstructuredContent(), a.schemaValidator); err != nil {
allErrs = append(allErrs, field.Invalid(field.NewPath(""), u.UnstructuredContent(), err.Error()))
}
allErrs = append(allErrs, apiservervalidation.ValidateCustomResource(nil, u.UnstructuredContent(), a.schemaValidator)...)
allErrs = append(allErrs, a.ValidateScaleSpec(ctx, u, scale)...)
allErrs = append(allErrs, a.ValidateScaleStatus(ctx, u, scale)...)
@ -119,9 +115,7 @@ func (a customResourceValidator) ValidateStatusUpdate(ctx context.Context, obj,
var allErrs field.ErrorList
allErrs = append(allErrs, validation.ValidateObjectMetaAccessorUpdate(objAccessor, oldAccessor, field.NewPath("metadata"))...)
if err = apiservervalidation.ValidateCustomResource(u.UnstructuredContent(), a.schemaValidator); err != nil {
allErrs = append(allErrs, field.Invalid(field.NewPath(""), u.UnstructuredContent(), err.Error()))
}
allErrs = append(allErrs, apiservervalidation.ValidateCustomResource(nil, u.UnstructuredContent(), a.schemaValidator)...)
allErrs = append(allErrs, a.ValidateScaleStatus(ctx, u, scale)...)
return allErrs

View File

@ -321,9 +321,9 @@ func TestCustomResourceValidationErrors(t *testing.T) {
ns := "not-the-default"
tests := []struct {
name string
instanceFn func() *unstructured.Unstructured
expectedError string
name string
instanceFn func() *unstructured.Unstructured
expectedErrors []string
}{
{
name: "bad alpha",
@ -332,7 +332,7 @@ func TestCustomResourceValidationErrors(t *testing.T) {
instance.Object["alpha"] = "foo_123!"
return instance
},
expectedError: "alpha in body should match '^[a-zA-Z0-9_]*$'",
expectedErrors: []string{"alpha in body should match '^[a-zA-Z0-9_]*$'"},
},
{
name: "bad beta",
@ -341,7 +341,7 @@ func TestCustomResourceValidationErrors(t *testing.T) {
instance.Object["beta"] = 5
return instance
},
expectedError: "beta in body should be greater than or equal to 10",
expectedErrors: []string{"beta in body should be greater than or equal to 10"},
},
{
name: "bad gamma",
@ -350,7 +350,7 @@ func TestCustomResourceValidationErrors(t *testing.T) {
instance.Object["gamma"] = "qux"
return instance
},
expectedError: "gamma in body should be one of [foo bar baz]",
expectedErrors: []string{`gamma: Unsupported value: "qux": supported values: "foo", "bar", "baz"`},
},
{
name: "bad delta",
@ -359,7 +359,10 @@ func TestCustomResourceValidationErrors(t *testing.T) {
instance.Object["delta"] = "foobarbaz"
return instance
},
expectedError: "must validate at least one schema (anyOf)\ndelta in body should be at most 5 chars long",
expectedErrors: []string{
"must validate at least one schema (anyOf)",
"delta in body should be at most 5 chars long",
},
},
{
name: "absent alpha and beta",
@ -377,7 +380,7 @@ func TestCustomResourceValidationErrors(t *testing.T) {
}
return instance
},
expectedError: ".alpha in body is required\n.beta in body is required",
expectedErrors: []string{"alpha: Required value", "beta: Required value"},
},
}
@ -388,13 +391,14 @@ func TestCustomResourceValidationErrors(t *testing.T) {
instanceToCreate.Object["apiVersion"] = fmt.Sprintf("%s/%s", noxuDefinition.Spec.Group, v.Name)
_, err := noxuResourceClient.Create(instanceToCreate, metav1.CreateOptions{})
if err == nil {
t.Errorf("%v: expected %v", tc.name, tc.expectedError)
t.Errorf("%v: expected %v", tc.name, tc.expectedErrors)
continue
}
// this only works when status errors contain the expect kind and version, so this effectively tests serializations too
if !strings.Contains(err.Error(), tc.expectedError) {
t.Errorf("%v: expected %v, got %v", tc.name, tc.expectedError, err)
continue
for _, expectedError := range tc.expectedErrors {
if !strings.Contains(err.Error(), expectedError) {
t.Errorf("%v: expected %v, got %v", tc.name, expectedError, err)
}
}
}
}