536 lines
20 KiB
Go
536 lines
20 KiB
Go
/*
|
|
Copyright 2020 The cert-manager Authors.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package readiness
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
coretesting "k8s.io/client-go/testing"
|
|
fakeclock "k8s.io/utils/clock/testing"
|
|
|
|
"github.com/cert-manager/cert-manager/internal/controller/certificates/policies"
|
|
cmapi "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
|
|
cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1"
|
|
controllerpkg "github.com/cert-manager/cert-manager/pkg/controller"
|
|
testpkg "github.com/cert-manager/cert-manager/pkg/controller/test"
|
|
"github.com/cert-manager/cert-manager/pkg/util/pki"
|
|
testcrypto "github.com/cert-manager/cert-manager/test/unit/crypto"
|
|
"github.com/cert-manager/cert-manager/test/unit/gen"
|
|
)
|
|
|
|
// policyEvaluatorBuilder returns a fake readyConditionFunc for ReadinessController.
|
|
func policyEvaluatorBuilder(c cmapi.CertificateCondition) policyEvaluatorFunc {
|
|
return func(chain policies.Chain, input policies.Input) cmapi.CertificateCondition {
|
|
return c
|
|
}
|
|
}
|
|
|
|
// renewalTimeBuilder returns a fake renewalTimeFunc for ReadinessController.
|
|
func renewalTimeBuilder(rt *metav1.Time) pki.RenewalTimeFunc {
|
|
return func(notBefore, notAfter time.Time, cert *metav1.Duration) *metav1.Time {
|
|
return rt
|
|
}
|
|
}
|
|
|
|
func TestProcessItem(t *testing.T) {
|
|
// now time is the current UTC time at the start of the test
|
|
now := time.Now().UTC()
|
|
metaNow := metav1.NewTime(now)
|
|
// private key to be used to generate X509 certificate
|
|
privKey := testcrypto.MustCreatePEMPrivateKey(t)
|
|
cert := &cmapi.Certificate{
|
|
ObjectMeta: metav1.ObjectMeta{Namespace: "testns", Name: "test"},
|
|
Spec: cmapi.CertificateSpec{
|
|
SecretName: "test-secret",
|
|
DNSNames: []string{"example.com"},
|
|
},
|
|
}
|
|
// base Secret to be used in tests
|
|
secret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Namespace: "testns",
|
|
Name: "test-secret",
|
|
},
|
|
}
|
|
tests := map[string]struct {
|
|
// key that should be passed to ProcessItem.
|
|
// if not set, the 'namespace/name' of the 'Certificate' field will be used.
|
|
// if neither is set, the key will be "".
|
|
key string
|
|
|
|
// cert to be loaded to fake clientset
|
|
cert *cmapi.Certificate
|
|
|
|
// whether we expect an update action against the Certificate
|
|
certShouldUpdate bool
|
|
|
|
// Certificate's Ready condition to be applied with the update
|
|
condition cmapi.CertificateCondition
|
|
|
|
// whether secret should be loaded into the fake clientset
|
|
// if notAfter, notBefore and renewalTime are set, an X509 cert will also be built and
|
|
// added as tls.crt value to the secret data
|
|
secretShouldExist bool
|
|
|
|
// notAfter will be used to build the X509 cert and
|
|
// as the updated Certificate's status.notAfter
|
|
notAfter *metav1.Time
|
|
|
|
// notBefore will be used to build the X509 cert and
|
|
// as the updated Certificate's status.notBefore
|
|
notBefore *metav1.Time
|
|
|
|
// renewalTime will be the updated Certificate's status.renewalTime
|
|
renewalTime *metav1.Time
|
|
|
|
wantsErr bool
|
|
}{
|
|
"do nothing if an empty 'key' is used": {},
|
|
"do nothing if an invalid 'key' is used": {
|
|
key: "abc/def/ghi",
|
|
},
|
|
"do nothing if a key references a Certificate that does not exist": {
|
|
key: "namespace/name",
|
|
},
|
|
"update status for a Certificate that is evaluated as Ready and whose spec.secretName secret contains a valid X509 cert": {
|
|
condition: cmapi.CertificateCondition{
|
|
Type: cmapi.CertificateConditionReady,
|
|
Status: cmmeta.ConditionTrue,
|
|
Reason: ReadyReason,
|
|
Message: "ready message",
|
|
LastTransitionTime: &metaNow,
|
|
},
|
|
cert: gen.CertificateFrom(cert),
|
|
certShouldUpdate: true,
|
|
secretShouldExist: true,
|
|
notAfter: func(m metav1.Time) *metav1.Time { return &m }(metav1.NewTime(now.Add(time.Hour * 2).Truncate(time.Second))),
|
|
notBefore: func(m metav1.Time) *metav1.Time { return &m }(metav1.NewTime(now.Truncate(time.Second))),
|
|
renewalTime: func(m metav1.Time) *metav1.Time { return &m }(metav1.NewTime(now.Add(time.Hour))),
|
|
},
|
|
"update status for a Certificate that is evaluated as not Ready and whose spec.secretName secret contains a valid X509 cert": {
|
|
condition: cmapi.CertificateCondition{
|
|
Type: cmapi.CertificateConditionReady,
|
|
Status: cmmeta.ConditionFalse,
|
|
Reason: "some reason",
|
|
Message: "some message",
|
|
LastTransitionTime: &metaNow,
|
|
},
|
|
cert: gen.CertificateFrom(cert),
|
|
certShouldUpdate: true,
|
|
secretShouldExist: true,
|
|
notAfter: func(m metav1.Time) *metav1.Time { return &m }(metav1.NewTime(now.Add(time.Hour * 2).Truncate(time.Second))),
|
|
notBefore: func(m metav1.Time) *metav1.Time { return &m }(metav1.NewTime(now.Truncate(time.Second))),
|
|
renewalTime: func(m metav1.Time) *metav1.Time { return &m }(metav1.NewTime(now.Add(time.Hour))),
|
|
},
|
|
"update status for a Certificate whose spec.secretName secret does not exist": {
|
|
condition: cmapi.CertificateCondition{
|
|
Type: cmapi.CertificateConditionReady,
|
|
Status: cmmeta.ConditionFalse,
|
|
Reason: "some reason",
|
|
Message: "some message",
|
|
LastTransitionTime: &metaNow,
|
|
},
|
|
cert: gen.CertificateFrom(cert),
|
|
|
|
certShouldUpdate: true,
|
|
},
|
|
"update status for a Certificate whose spec.secretName secret does not contain a TLS certificate": {
|
|
condition: cmapi.CertificateCondition{
|
|
Type: cmapi.CertificateConditionReady,
|
|
Status: cmmeta.ConditionFalse,
|
|
Reason: "some reason",
|
|
Message: "some message",
|
|
LastTransitionTime: &metaNow,
|
|
},
|
|
cert: gen.CertificateFrom(cert),
|
|
certShouldUpdate: true,
|
|
secretShouldExist: true,
|
|
},
|
|
"update status for a Certificate that currently has Ready condition false, but policy evaluates to True": {
|
|
condition: cmapi.CertificateCondition{
|
|
Type: cmapi.CertificateConditionReady,
|
|
Status: cmmeta.ConditionTrue,
|
|
Reason: ReadyReason,
|
|
Message: "ready message",
|
|
LastTransitionTime: &metaNow,
|
|
},
|
|
cert: gen.CertificateFrom(cert, gen.SetCertificateStatusCondition(
|
|
cmapi.CertificateCondition{
|
|
Type: cmapi.CertificateConditionReady,
|
|
Status: cmmeta.ConditionFalse,
|
|
Reason: "some reason",
|
|
Message: "some message",
|
|
})),
|
|
certShouldUpdate: true,
|
|
secretShouldExist: true,
|
|
},
|
|
"update status for a Certificate that already has some other condition": {
|
|
condition: cmapi.CertificateCondition{
|
|
Type: cmapi.CertificateConditionReady,
|
|
Status: cmmeta.ConditionFalse,
|
|
Reason: "some reason",
|
|
Message: "some message",
|
|
LastTransitionTime: &metaNow,
|
|
},
|
|
cert: gen.CertificateFrom(cert, gen.SetCertificateStatusCondition(
|
|
cmapi.CertificateCondition{
|
|
Type: cmapi.CertificateConditionIssuing,
|
|
Status: cmmeta.ConditionTrue,
|
|
Reason: "some reason",
|
|
Message: "some message",
|
|
})),
|
|
certShouldUpdate: true,
|
|
},
|
|
"update status for a Certificate that has Ready condition set to true, but policy evaluator fails": {
|
|
certShouldUpdate: true,
|
|
condition: cmapi.CertificateCondition{
|
|
Type: cmapi.CertificateConditionReady,
|
|
Status: cmmeta.ConditionFalse,
|
|
Reason: "some reason",
|
|
Message: "some message",
|
|
LastTransitionTime: &metaNow,
|
|
},
|
|
cert: gen.CertificateFrom(cert, gen.SetCertificateStatusCondition(
|
|
cmapi.CertificateCondition{
|
|
Type: cmapi.CertificateConditionReady,
|
|
Status: cmmeta.ConditionTrue,
|
|
Reason: ReadyReason,
|
|
Message: "ready message",
|
|
})),
|
|
},
|
|
"update status for a Certificate that has a Ready condition and the policy evaluates to True- should remain True": {
|
|
condition: cmapi.CertificateCondition{
|
|
Type: cmapi.CertificateConditionReady,
|
|
Status: cmmeta.ConditionTrue,
|
|
Reason: ReadyReason,
|
|
Message: "ready message",
|
|
LastTransitionTime: &metaNow,
|
|
},
|
|
cert: gen.CertificateFrom(cert, gen.SetCertificateStatusCondition(
|
|
cmapi.CertificateCondition{
|
|
Type: cmapi.CertificateConditionReady,
|
|
Status: cmmeta.ConditionTrue,
|
|
Reason: ReadyReason,
|
|
Message: "ready message",
|
|
LastTransitionTime: &metaNow,
|
|
})),
|
|
secretShouldExist: true,
|
|
certShouldUpdate: false,
|
|
},
|
|
}
|
|
for name, test := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
// Create and initialise a new unit test builder.
|
|
builder := &testpkg.Builder{
|
|
T: t,
|
|
// Fix the clock to be able to set lastTransitionTime on Certificate's Ready condition.
|
|
Clock: fakeclock.NewFakeClock(now),
|
|
}
|
|
if test.cert != nil {
|
|
// Ensures cert is loaded into the builder's fake clientset.
|
|
builder.CertManagerObjects = append(builder.CertManagerObjects, test.cert)
|
|
}
|
|
|
|
if test.secretShouldExist {
|
|
mods := make([]gen.SecretModifier, 0)
|
|
// If the test scenario needs a secret with a valid X509 cert.
|
|
if test.notBefore != nil && test.notAfter != nil {
|
|
x509Bytes := testcrypto.MustCreateCertWithNotBeforeAfter(t, privKey, cert, test.notBefore.Time, test.notAfter.Time)
|
|
mods = append(mods,
|
|
gen.SetSecretData(map[string][]byte{
|
|
"tls.crt": x509Bytes,
|
|
}))
|
|
}
|
|
// Ensure secret is loaded into the builder's fake clientset.
|
|
builder.KubeObjects = append(builder.KubeObjects,
|
|
gen.SecretFrom(secret, mods...))
|
|
}
|
|
|
|
builder.Init()
|
|
|
|
// Register informers used by the controller using the registration wrapper.
|
|
w := &controllerWrapper{}
|
|
_, _, err := w.Register(builder.Context)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Override controller's readyCondition func with a fake that returns test.condition.
|
|
w.controller.policyEvaluator = policyEvaluatorBuilder(test.condition)
|
|
|
|
// Override controller's renewalTime func with a fake that returns test.renewalTime.
|
|
w.controller.renewalTimeCalculator = renewalTimeBuilder(test.renewalTime)
|
|
|
|
// If Certificate's status should be updated,
|
|
// build the expected Certificate and use it to set the expected update action on builder.
|
|
if test.certShouldUpdate {
|
|
c := gen.CertificateFrom(test.cert,
|
|
gen.SetCertificateStatusCondition(test.condition))
|
|
|
|
// gen package functions don't accept pointers- we need to test setting these values to nil in some scenarios.
|
|
c.Status.NotAfter = test.notAfter
|
|
c.Status.NotBefore = test.notBefore
|
|
c.Status.RenewalTime = test.renewalTime
|
|
|
|
builder.ExpectedActions = append(builder.ExpectedActions,
|
|
testpkg.NewAction(coretesting.NewUpdateSubresourceAction(
|
|
cmapi.SchemeGroupVersion.WithResource("certificates"),
|
|
"status",
|
|
c.Namespace,
|
|
c)))
|
|
}
|
|
|
|
// Start the informers and begin processing updates.
|
|
builder.Start()
|
|
defer builder.Stop()
|
|
|
|
key := test.key
|
|
if key == "" && cert != nil {
|
|
key, err = controllerpkg.KeyFunc(cert)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// Call ProcessItem
|
|
err = w.controller.ProcessItem(context.Background(), key)
|
|
if test.wantsErr != (err != nil) {
|
|
t.Errorf("expected error: %v, got : %v", test.wantsErr, err)
|
|
}
|
|
|
|
if err := builder.AllActionsExecuted(); err != nil {
|
|
builder.T.Error(err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test the evaluation of the ordered policy chain as a whole.
|
|
func TestNewReadinessPolicyChain(t *testing.T) {
|
|
clock := &fakeclock.FakeClock{}
|
|
privKey := testcrypto.MustCreatePEMPrivateKey(t)
|
|
tests := map[string]struct {
|
|
// policy inputs
|
|
cert *cmapi.Certificate
|
|
cr *cmapi.CertificateRequest
|
|
secret *corev1.Secret
|
|
|
|
// expected outputs
|
|
reason, message string
|
|
violationFound bool
|
|
}{
|
|
"Certificate not Ready if Secret is missing": {
|
|
cert: gen.Certificate("test", gen.SetCertificateSecretName("something")),
|
|
reason: policies.DoesNotExist,
|
|
message: "Issuing certificate as Secret does not exist",
|
|
violationFound: true,
|
|
},
|
|
"Certificate not Ready as Secret does not contain any data": {
|
|
cert: gen.Certificate("test", gen.SetCertificateSecretName("something")),
|
|
secret: gen.Secret("something"),
|
|
reason: policies.MissingData,
|
|
message: "Issuing certificate as Secret does not contain any data",
|
|
violationFound: true,
|
|
},
|
|
"Certificate not Ready as Secret is missing private key": {
|
|
cert: gen.Certificate("test", gen.SetCertificateSecretName("something")),
|
|
secret: gen.Secret("something", gen.SetSecretData(map[string][]byte{corev1.TLSCertKey: []byte("test")})),
|
|
reason: policies.MissingData,
|
|
message: "Issuing certificate as Secret does not contain a private key",
|
|
violationFound: true,
|
|
},
|
|
"Certificate not Ready as Secret is missing certificate": {
|
|
cert: gen.Certificate("test", gen.SetCertificateSecretName("something")),
|
|
secret: gen.Secret("something", gen.SetSecretData(map[string][]byte{corev1.TLSPrivateKeyKey: []byte("test")})),
|
|
reason: policies.MissingData,
|
|
message: "Issuing certificate as Secret does not contain a certificate",
|
|
violationFound: true,
|
|
},
|
|
"Certificate not Ready as Secret contains corrupt private key and certificate data": {
|
|
cert: &cmapi.Certificate{Spec: cmapi.CertificateSpec{SecretName: "something"}},
|
|
secret: gen.Secret("something", gen.SetSecretData(
|
|
map[string][]byte{
|
|
corev1.TLSPrivateKeyKey: []byte("test"),
|
|
corev1.TLSCertKey: []byte("test"),
|
|
})),
|
|
reason: policies.InvalidKeyPair,
|
|
message: "Issuing certificate as Secret contains invalid private key data: error decoding private key PEM block",
|
|
violationFound: true,
|
|
},
|
|
"Certificate not Ready as Secret contains corrupt certificate data": {
|
|
cert: &cmapi.Certificate{Spec: cmapi.CertificateSpec{SecretName: "something"}},
|
|
secret: gen.Secret("something", gen.SetSecretData(
|
|
map[string][]byte{
|
|
corev1.TLSPrivateKeyKey: privKey,
|
|
corev1.TLSCertKey: []byte("test"),
|
|
})),
|
|
reason: policies.InvalidCertificate,
|
|
message: "Issuing certificate as Secret contains an invalid certificate: error decoding certificate PEM block",
|
|
violationFound: true,
|
|
},
|
|
"Certificate not Ready as Secret contains a non-matching key-pair": {
|
|
cert: &cmapi.Certificate{Spec: cmapi.CertificateSpec{SecretName: "something"}},
|
|
secret: gen.Secret("something", gen.SetSecretData(
|
|
map[string][]byte{
|
|
corev1.TLSPrivateKeyKey: privKey,
|
|
// generate a different private key
|
|
corev1.TLSCertKey: testcrypto.MustCreateCert(t, testcrypto.MustCreatePEMPrivateKey(t),
|
|
gen.Certificate("something else", gen.SetCertificateCommonName("example.com"))),
|
|
})),
|
|
reason: policies.InvalidKeyPair,
|
|
message: "Issuing certificate as Secret contains a private key that does not match the certificate",
|
|
violationFound: true,
|
|
},
|
|
"Certificate not Ready when CertificateRequest does not match certificate spec": {
|
|
cert: gen.Certificate("something",
|
|
gen.SetCertificateCommonName("new.example.com"),
|
|
gen.SetCertificateIssuer(
|
|
cmmeta.ObjectReference{Name: "testissuer", Kind: "IssuerKind", Group: "group.example.com"})),
|
|
secret: gen.Secret("something",
|
|
gen.SetSecretAnnotations(
|
|
map[string]string{
|
|
cmapi.IssuerNameAnnotationKey: "testissuer",
|
|
cmapi.IssuerKindAnnotationKey: "IssuerKind",
|
|
cmapi.IssuerGroupAnnotationKey: "group.example.com",
|
|
},
|
|
),
|
|
gen.SetSecretData(
|
|
map[string][]byte{
|
|
corev1.TLSPrivateKeyKey: privKey,
|
|
corev1.TLSCertKey: testcrypto.MustCreateCert(t, privKey,
|
|
gen.Certificate("something else", gen.SetCertificateCommonName("old.example.com"))),
|
|
},
|
|
),
|
|
),
|
|
cr: gen.CertificateRequest("something",
|
|
gen.SetCertificateRequestIssuer(
|
|
cmmeta.ObjectReference{
|
|
Name: "testissuer",
|
|
Kind: "IssuerKind",
|
|
Group: "group.example.com",
|
|
},
|
|
),
|
|
gen.SetCertificateRequestCSR(
|
|
testcrypto.MustGenerateCSRImpl(t, privKey,
|
|
gen.Certificate("somethingelse",
|
|
gen.SetCertificateCommonName("old.example.com"))))),
|
|
reason: policies.RequestChanged,
|
|
message: "Fields on existing CertificateRequest resource not up to date: [spec.commonName]",
|
|
violationFound: true,
|
|
},
|
|
"Certificate is not Ready when it has expired": {
|
|
cert: gen.Certificate("something",
|
|
gen.SetCertificateCommonName("new.example.com"),
|
|
gen.SetCertificateIssuer(cmmeta.ObjectReference{
|
|
Name: "testissuer",
|
|
Kind: "IssuerKind",
|
|
Group: "group.example.com",
|
|
})),
|
|
secret: gen.Secret("something",
|
|
gen.SetSecretAnnotations(map[string]string{
|
|
cmapi.IssuerNameAnnotationKey: "testissuer",
|
|
cmapi.IssuerKindAnnotationKey: "IssuerKind",
|
|
cmapi.IssuerGroupAnnotationKey: "group.example.com",
|
|
}),
|
|
gen.SetSecretData(
|
|
map[string][]byte{
|
|
corev1.TLSPrivateKeyKey: privKey,
|
|
corev1.TLSCertKey: testcrypto.MustCreateCertWithNotBeforeAfter(t, privKey,
|
|
gen.Certificate("something", gen.SetCertificateCommonName("new.example.com")),
|
|
clock.Now().Add(-3*time.Hour), clock.Now().Add(-1*time.Hour),
|
|
),
|
|
},
|
|
)),
|
|
cr: gen.CertificateRequest("something",
|
|
gen.SetCertificateRequestIssuer(cmmeta.ObjectReference{
|
|
Name: "testissuer",
|
|
Kind: "IssuerKind",
|
|
Group: "group.example.com",
|
|
}),
|
|
gen.SetCertificateRequestCSR(testcrypto.MustGenerateCSRImpl(t, privKey,
|
|
gen.Certificate("something",
|
|
gen.SetCertificateCommonName("new.example.com")))),
|
|
),
|
|
reason: policies.Expired,
|
|
message: "Certificate expired on Sun, 31 Dec 0000 23:00:00 UTC",
|
|
violationFound: true,
|
|
},
|
|
"Certificate is Ready, no policy violations found": {
|
|
cert: gen.Certificate("something",
|
|
gen.SetCertificateCommonName("new.example.com"),
|
|
gen.SetCertificateIssuer(cmmeta.ObjectReference{
|
|
Name: "testissuer",
|
|
Kind: "IssuerKind",
|
|
Group: "group.example.com",
|
|
})),
|
|
secret: gen.Secret("something",
|
|
gen.SetSecretAnnotations(
|
|
map[string]string{
|
|
cmapi.IssuerNameAnnotationKey: "testissuer",
|
|
cmapi.IssuerKindAnnotationKey: "IssuerKind",
|
|
cmapi.IssuerGroupAnnotationKey: "group.example.com",
|
|
}),
|
|
gen.SetSecretData(
|
|
map[string][]byte{
|
|
corev1.TLSPrivateKeyKey: privKey,
|
|
corev1.TLSCertKey: testcrypto.MustCreateCertWithNotBeforeAfter(t, privKey,
|
|
&cmapi.Certificate{Spec: cmapi.CertificateSpec{CommonName: "new.example.com"}},
|
|
clock.Now(), clock.Now().Add(time.Hour*3),
|
|
),
|
|
},
|
|
)),
|
|
cr: gen.CertificateRequest("something",
|
|
gen.SetCertificateRequestIssuer(
|
|
cmmeta.ObjectReference{
|
|
Name: "testissuer",
|
|
Kind: "IssuerKind",
|
|
Group: "group.example.com",
|
|
},
|
|
),
|
|
gen.SetCertificateRequestCSR(testcrypto.MustGenerateCSRImpl(t, privKey,
|
|
gen.Certificate("something",
|
|
gen.SetCertificateCommonName("new.example.com")))),
|
|
),
|
|
reason: "",
|
|
message: "",
|
|
},
|
|
}
|
|
policyChain := policies.NewReadinessPolicyChain(clock)
|
|
for name, test := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
reason, message, violationFound := policyChain.Evaluate(policies.Input{
|
|
Certificate: test.cert,
|
|
CurrentRevisionRequest: test.cr,
|
|
Secret: test.secret,
|
|
})
|
|
if test.reason != reason {
|
|
t.Errorf("unexpected 'reason' exp=%s, got=%s", test.reason, reason)
|
|
}
|
|
if test.message != message {
|
|
t.Errorf("unexpected 'message' exp=%s, got=%s", test.message, message)
|
|
}
|
|
if test.violationFound != violationFound {
|
|
t.Errorf("unexpected 'violationFound' exp=%v, got=%v", test.violationFound, violationFound)
|
|
}
|
|
})
|
|
}
|
|
}
|