// 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" "k8s.io/apimachinery/pkg/types" kubeinformers "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" kubernetesfake "k8s.io/client-go/kubernetes/fake" corev1client "k8s.io/client-go/kubernetes/typed/core/v1" 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) } testRV := "rv_001" testUID := types.UID("uid_002") kubeInformerClient := kubernetesfake.NewSimpleClientset() name := certsSecretResourceName namespace := "some-namespace" if test.fillSecretData != nil { secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, ResourceVersion: testRV, UID: testUID, }, 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, ) trackDeleteClient := &clientWrapper{Interface: kubeAPIClient, opts: &[]metav1.DeleteOptions{}} c := NewCertsExpirerController( namespace, certsSecretResourceName, trackDeleteClient, 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) if test.wantDelete { require.Len(t, *trackDeleteClient.opts, 1) require.Equal(t, metav1.DeleteOptions{ Preconditions: &metav1.Preconditions{ UID: &testUID, ResourceVersion: &testRV, }, }, (*trackDeleteClient.opts)[0]) } else { require.Len(t, *trackDeleteClient.opts, 0) } }) } } type clientWrapper struct { kubernetes.Interface opts *[]metav1.DeleteOptions } func (c *clientWrapper) CoreV1() corev1client.CoreV1Interface { return &coreWrapper{CoreV1Interface: c.Interface.CoreV1(), opts: c.opts} } type coreWrapper struct { corev1client.CoreV1Interface opts *[]metav1.DeleteOptions } func (c *coreWrapper) Secrets(namespace string) corev1client.SecretInterface { return &secretsWrapper{SecretInterface: c.CoreV1Interface.Secrets(namespace), opts: c.opts} } type secretsWrapper struct { corev1client.SecretInterface opts *[]metav1.DeleteOptions } func (s *secretsWrapper) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { *s.opts = append(*s.opts, opts) return s.SecretInterface.Delete(ctx, name, opts) }