// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package apicerts import ( "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "errors" "testing" "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" kubeinformers "k8s.io/client-go/informers" kubernetesfake "k8s.io/client-go/kubernetes/fake" kubetesting "k8s.io/client-go/testing" "go.pinniped.dev/internal/controllerlib" "go.pinniped.dev/internal/testutil" ) func TestExpirerControllerFilters(t *testing.T) { t.Parallel() const certsSecretResourceName = "some-resource-name" tests := []struct { name string namespace string secret corev1.Secret want bool }{ { name: "good name, good namespace", namespace: "good-namespace", secret: corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: certsSecretResourceName, Namespace: "good-namespace", }, }, want: true, }, { name: "bad name, good namespace", namespace: "good-namespacee", secret: corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "bad-name", Namespace: "good-namespace", }, }, want: false, }, { name: "good name, bad namespace", namespace: "good-namespacee", secret: corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: certsSecretResourceName, Namespace: "bad-namespace", }, }, want: false, }, { name: "bad name, bad namespace", namespace: "good-namespacee", secret: corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "bad-name", Namespace: "bad-namespace", }, }, want: false, }, } for _, test := range tests { test := test t.Run(test.name+"-"+test.namespace, func(t *testing.T) { t.Parallel() secretsInformer := kubeinformers.NewSharedInformerFactory( kubernetesfake.NewSimpleClientset(), 0, ).Core().V1().Secrets() withInformer := testutil.NewObservableWithInformerOption() _ = NewCertsExpirerController( test.namespace, certsSecretResourceName, nil, // k8sClient, not needed secretsInformer, withInformer.WithInformer, 0, // renewBefore, not needed "", // not needed ) unrelated := corev1.Secret{} filter := withInformer.GetFilterForInformer(secretsInformer) require.Equal(t, test.want, filter.Add(&test.secret)) require.Equal(t, test.want, filter.Update(&unrelated, &test.secret)) require.Equal(t, test.want, filter.Update(&test.secret, &unrelated)) require.Equal(t, test.want, filter.Delete(&test.secret)) }) } } func TestExpirerControllerSync(t *testing.T) { t.Parallel() const certsSecretResourceName = "some-resource-name" const fakeTestKey = "some-awesome-key" tests := []struct { name string renewBefore time.Duration fillSecretData func(*testing.T, map[string][]byte) configKubeAPIClient func(*kubernetesfake.Clientset) wantDelete bool wantError string }{ { name: "secret does not exist", wantDelete: false, }, { name: "secret missing key", fillSecretData: func(t *testing.T, m map[string][]byte) {}, wantDelete: false, wantError: `failed to get cert bounds for secret "some-resource-name" with key "some-awesome-key": failed to find certificate`, }, { name: "lifetime below threshold", renewBefore: 7 * time.Hour, fillSecretData: func(t *testing.T, m map[string][]byte) { certPEM, _, err := testutil.CreateCertificate( time.Now().Add(-5*time.Hour), time.Now().Add(5*time.Hour), ) require.NoError(t, err) m[fakeTestKey] = certPEM }, wantDelete: false, }, { name: "lifetime above threshold", renewBefore: 3 * time.Hour, fillSecretData: func(t *testing.T, m map[string][]byte) { certPEM, _, err := testutil.CreateCertificate( time.Now().Add(-5*time.Hour), time.Now().Add(5*time.Hour), ) require.NoError(t, err) m[fakeTestKey] = certPEM }, wantDelete: true, }, { name: "cert expired", renewBefore: 3 * time.Hour, fillSecretData: func(t *testing.T, m map[string][]byte) { certPEM, _, err := testutil.CreateCertificate( time.Now().Add(-2*time.Hour), time.Now().Add(-1*time.Hour), ) require.NoError(t, err) m[fakeTestKey] = certPEM }, wantDelete: true, }, { name: "delete failure", renewBefore: 3 * time.Hour, fillSecretData: func(t *testing.T, m map[string][]byte) { certPEM, _, err := testutil.CreateCertificate( time.Now().Add(-5*time.Hour), time.Now().Add(5*time.Hour), ) require.NoError(t, err) m[fakeTestKey] = certPEM }, configKubeAPIClient: func(c *kubernetesfake.Clientset) { c.PrependReactor("delete", "secrets", func(_ kubetesting.Action) (bool, runtime.Object, error) { return true, nil, errors.New("delete failed: some delete error") }) }, wantError: "delete failed: some delete error", }, { name: "parse cert failure", fillSecretData: func(t *testing.T, m map[string][]byte) { privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) m[fakeTestKey], err = x509.MarshalPKCS8PrivateKey(privateKey) require.NoError(t, err) }, wantDelete: false, wantError: `failed to get cert bounds for secret "some-resource-name" with key "some-awesome-key": failed to decode certificate PEM`, }, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(context.Background()) defer cancel() kubeAPIClient := kubernetesfake.NewSimpleClientset() if test.configKubeAPIClient != nil { test.configKubeAPIClient(kubeAPIClient) } kubeInformerClient := kubernetesfake.NewSimpleClientset() name := certsSecretResourceName namespace := "some-namespace" if test.fillSecretData != nil { secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, Data: map[string][]byte{}, } test.fillSecretData(t, secret.Data) require.NoError(t, kubeAPIClient.Tracker().Add(secret)) require.NoError(t, kubeInformerClient.Tracker().Add(secret)) } kubeInformers := kubeinformers.NewSharedInformerFactory( kubeInformerClient, 0, ) c := NewCertsExpirerController( namespace, certsSecretResourceName, kubeAPIClient, kubeInformers.Core().V1().Secrets(), controllerlib.WithInformer, test.renewBefore, fakeTestKey, ) // Must start informers before calling TestRunSynchronously(). kubeInformers.Start(ctx.Done()) controllerlib.TestRunSynchronously(t, c) err := controllerlib.TestSync(t, c, controllerlib.Context{ Context: ctx, }) if test.wantError != "" { require.EqualError(t, err, test.wantError) return } require.NoError(t, err) exActions := []kubetesting.Action{} if test.wantDelete { exActions = append( exActions, kubetesting.NewDeleteAction( schema.GroupVersionResource{ Group: "", Version: "v1", Resource: "secrets", }, namespace, name, ), ) } acActions := kubeAPIClient.Actions() require.Equal(t, exActions, acActions) }) } }