// Copyright 2020 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package supervisorconfig import ( "bytes" "context" "crypto/x509" "encoding/pem" "errors" "io" "io/ioutil" "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" configv1alpha1 "go.pinniped.dev/generated/1.19/apis/config/v1alpha1" pinnipedfake "go.pinniped.dev/generated/1.19/client/clientset/versioned/fake" pinnipedinformers "go.pinniped.dev/generated/1.19/client/informers/externalversions" "go.pinniped.dev/internal/controllerlib" "go.pinniped.dev/internal/testutil" ) func TestJWKSControllerFilterSecret(t *testing.T) { t.Parallel() tests := []struct { name string secret corev1.Secret wantAdd bool wantUpdate bool wantDelete bool wantParent controllerlib.Key }{ { name: "no owner reference", secret: corev1.Secret{ ObjectMeta: metav1.ObjectMeta{}, }, }, { name: "owner reference without correct APIVersion", secret: corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Namespace: "some-namespace", OwnerReferences: []metav1.OwnerReference{ { Kind: "OIDCProviderConfig", Name: "some-name", Controller: boolPtr(true), }, }, }, }, }, { name: "owner reference without correct Kind", secret: corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Namespace: "some-namespace", OwnerReferences: []metav1.OwnerReference{ { APIVersion: configv1alpha1.SchemeGroupVersion.String(), Name: "some-name", Controller: boolPtr(true), }, }, }, }, }, { name: "owner reference without controller set to true", secret: corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Namespace: "some-namespace", OwnerReferences: []metav1.OwnerReference{ { APIVersion: configv1alpha1.SchemeGroupVersion.String(), Kind: "OIDCProviderConfig", Name: "some-name", }, }, }, }, }, { name: "correct owner reference", secret: corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Namespace: "some-namespace", OwnerReferences: []metav1.OwnerReference{ { APIVersion: configv1alpha1.SchemeGroupVersion.String(), Kind: "OIDCProviderConfig", Name: "some-name", Controller: boolPtr(true), }, }, }, }, wantAdd: true, wantUpdate: true, wantDelete: true, wantParent: controllerlib.Key{Namespace: "some-namespace", Name: "some-name"}, }, { name: "multiple owner references", secret: corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Namespace: "some-namespace", OwnerReferences: []metav1.OwnerReference{ { Kind: "UnrelatedKind", }, { APIVersion: configv1alpha1.SchemeGroupVersion.String(), Kind: "OIDCProviderConfig", Name: "some-name", Controller: boolPtr(true), }, }, }, }, wantAdd: true, wantUpdate: true, wantDelete: true, wantParent: controllerlib.Key{Namespace: "some-namespace", Name: "some-name"}, }, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() secretInformer := kubeinformers.NewSharedInformerFactory( kubernetesfake.NewSimpleClientset(), 0, ).Core().V1().Secrets() opcInformer := pinnipedinformers.NewSharedInformerFactory( pinnipedfake.NewSimpleClientset(), 0, ).Config().V1alpha1().OIDCProviderConfigs() withInformer := testutil.NewObservableWithInformerOption() _ = NewJWKSController( nil, // kubeClient, not needed nil, // pinnipedClient, not needed secretInformer, opcInformer, withInformer.WithInformer, ) unrelated := corev1.Secret{} filter := withInformer.GetFilterForInformer(secretInformer) require.Equal(t, test.wantAdd, filter.Add(&test.secret)) require.Equal(t, test.wantUpdate, filter.Update(&unrelated, &test.secret)) require.Equal(t, test.wantUpdate, filter.Update(&test.secret, &unrelated)) require.Equal(t, test.wantDelete, filter.Delete(&test.secret)) require.Equal(t, test.wantParent, filter.Parent(&test.secret)) }) } } func TestJWKSControllerFilterOPC(t *testing.T) { t.Parallel() tests := []struct { name string opc configv1alpha1.OIDCProviderConfig wantAdd bool wantUpdate bool wantDelete bool wantParent controllerlib.Key }{ { name: "anything goes", opc: configv1alpha1.OIDCProviderConfig{}, wantAdd: true, wantUpdate: true, wantDelete: true, wantParent: controllerlib.Key{}, }, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() secretInformer := kubeinformers.NewSharedInformerFactory( kubernetesfake.NewSimpleClientset(), 0, ).Core().V1().Secrets() opcInformer := pinnipedinformers.NewSharedInformerFactory( pinnipedfake.NewSimpleClientset(), 0, ).Config().V1alpha1().OIDCProviderConfigs() withInformer := testutil.NewObservableWithInformerOption() _ = NewJWKSController( nil, // kubeClient, not needed nil, // pinnipedClient, not needed secretInformer, opcInformer, withInformer.WithInformer, ) unrelated := configv1alpha1.OIDCProviderConfig{} filter := withInformer.GetFilterForInformer(opcInformer) require.Equal(t, test.wantAdd, filter.Add(&test.opc)) require.Equal(t, test.wantUpdate, filter.Update(&unrelated, &test.opc)) require.Equal(t, test.wantUpdate, filter.Update(&test.opc, &unrelated)) require.Equal(t, test.wantDelete, filter.Delete(&test.opc)) require.Equal(t, test.wantParent, filter.Parent(&test.opc)) }) } } func TestJWKSControllerSync(t *testing.T) { // We shouldn't run this test in parallel since it messes with a global function (generateKey). const namespace = "tuna-namespace" goodRSAKeyPEM, err := ioutil.ReadFile("testdata/good-rsa-key.pem") require.NoError(t, err) block, _ := pem.Decode(goodRSAKeyPEM) require.NotNil(t, block, "expected block to be non-nil...is goodRSAKeyPEM a valid PEM?") goodRSAKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) require.NoError(t, err) opcGVR := schema.GroupVersionResource{ Group: configv1alpha1.SchemeGroupVersion.Group, Version: configv1alpha1.SchemeGroupVersion.Version, Resource: "oidcproviderconfigs", } goodOPC := &configv1alpha1.OIDCProviderConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "good-opc", Namespace: namespace, UID: "good-opc-uid", }, Spec: configv1alpha1.OIDCProviderConfigSpec{ Issuer: "https://some-issuer.com", }, } goodOPCWithStatus := goodOPC.DeepCopy() goodOPCWithStatus.Status.JWKSSecret.Name = goodOPCWithStatus.Name + "-jwks" secretGVR := schema.GroupVersionResource{ Group: corev1.SchemeGroupVersion.Group, Version: corev1.SchemeGroupVersion.Version, Resource: "secrets", } newSecret := func(activeJWKPath, jwksPath string) *corev1.Secret { s := corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: goodOPCWithStatus.Status.JWKSSecret.Name, Namespace: namespace, OwnerReferences: []metav1.OwnerReference{ { APIVersion: opcGVR.GroupVersion().String(), Kind: "OIDCProviderConfig", Name: goodOPC.Name, UID: goodOPC.UID, BlockOwnerDeletion: boolPtr(true), Controller: boolPtr(true), }, }, }, } s.Data = make(map[string][]byte) if activeJWKPath != "" { s.Data["activeJWK"] = read(t, activeJWKPath) } if jwksPath != "" { s.Data["jwks"] = read(t, jwksPath) } return &s } goodSecret := newSecret("testdata/good-jwk.json", "testdata/good-jwks.json") tests := []struct { name string key controllerlib.Key secrets []*corev1.Secret configKubeClient func(*kubernetesfake.Clientset) configPinnipedClient func(*pinnipedfake.Clientset) opcs []*configv1alpha1.OIDCProviderConfig generateKeyErr error wantGenerateKeyCount int wantSecretActions []kubetesting.Action wantOPCActions []kubetesting.Action wantError string }{ { name: "new opc with no secret", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPC, }, wantGenerateKeyCount: 1, wantSecretActions: []kubetesting.Action{ kubetesting.NewGetAction(secretGVR, namespace, goodSecret.Name), kubetesting.NewCreateAction(secretGVR, namespace, goodSecret), }, wantOPCActions: []kubetesting.Action{ kubetesting.NewGetAction(opcGVR, namespace, goodOPC.Name), kubetesting.NewUpdateAction(opcGVR, namespace, goodOPCWithStatus), }, }, { name: "opc without status with existing secret", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPC, }, secrets: []*corev1.Secret{ goodSecret, }, wantGenerateKeyCount: 1, wantSecretActions: []kubetesting.Action{ kubetesting.NewGetAction(secretGVR, namespace, goodSecret.Name), }, wantOPCActions: []kubetesting.Action{ kubetesting.NewGetAction(opcGVR, namespace, goodOPC.Name), kubetesting.NewUpdateAction(opcGVR, namespace, goodOPCWithStatus), }, }, { name: "existing opc with no secret", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPCWithStatus, }, wantGenerateKeyCount: 1, wantSecretActions: []kubetesting.Action{ kubetesting.NewGetAction(secretGVR, namespace, goodSecret.Name), kubetesting.NewCreateAction(secretGVR, namespace, goodSecret), }, wantOPCActions: []kubetesting.Action{ kubetesting.NewGetAction(opcGVR, namespace, goodOPC.Name), }, }, { name: "existing opc with existing secret", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPCWithStatus, }, secrets: []*corev1.Secret{ goodSecret, }, }, { name: "deleted opc", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, // Nothing to do here since Kube will garbage collect our child secret via its OwnerReference. }, { name: "missing jwk in secret", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPCWithStatus, }, secrets: []*corev1.Secret{ newSecret("", "testdata/good-jwks.json"), }, wantGenerateKeyCount: 1, wantSecretActions: []kubetesting.Action{ kubetesting.NewGetAction(secretGVR, namespace, goodSecret.Name), kubetesting.NewUpdateAction(secretGVR, namespace, goodSecret), }, wantOPCActions: []kubetesting.Action{ kubetesting.NewGetAction(opcGVR, namespace, goodOPC.Name), }, }, { name: "missing jwks in secret", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPCWithStatus, }, secrets: []*corev1.Secret{ newSecret("testdata/good-jwk.json", ""), }, wantGenerateKeyCount: 1, wantSecretActions: []kubetesting.Action{ kubetesting.NewGetAction(secretGVR, namespace, goodSecret.Name), kubetesting.NewUpdateAction(secretGVR, namespace, goodSecret), }, wantOPCActions: []kubetesting.Action{ kubetesting.NewGetAction(opcGVR, namespace, goodOPC.Name), }, }, { name: "invalid jwk JSON in secret", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPCWithStatus, }, secrets: []*corev1.Secret{ newSecret("testdata/not-json.txt", "testdata/good-jwks.json"), }, wantGenerateKeyCount: 1, wantSecretActions: []kubetesting.Action{ kubetesting.NewGetAction(secretGVR, namespace, goodSecret.Name), kubetesting.NewUpdateAction(secretGVR, namespace, goodSecret), }, wantOPCActions: []kubetesting.Action{ kubetesting.NewGetAction(opcGVR, namespace, goodOPC.Name), }, }, { name: "invalid jwks JSON in secret", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPCWithStatus, }, secrets: []*corev1.Secret{ newSecret("testdata/good-jwk.json", "testdata/not-json.txt"), }, wantGenerateKeyCount: 1, wantSecretActions: []kubetesting.Action{ kubetesting.NewGetAction(secretGVR, namespace, goodSecret.Name), kubetesting.NewUpdateAction(secretGVR, namespace, goodSecret), }, wantOPCActions: []kubetesting.Action{ kubetesting.NewGetAction(opcGVR, namespace, goodOPC.Name), }, }, { name: "public jwk in secret", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPCWithStatus, }, secrets: []*corev1.Secret{ newSecret("testdata/public-jwk.json", "testdata/good-jwks.json"), }, wantGenerateKeyCount: 1, wantSecretActions: []kubetesting.Action{ kubetesting.NewGetAction(secretGVR, namespace, goodSecret.Name), kubetesting.NewUpdateAction(secretGVR, namespace, goodSecret), }, wantOPCActions: []kubetesting.Action{ kubetesting.NewGetAction(opcGVR, namespace, goodOPC.Name), }, }, { name: "private jwks in secret", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPCWithStatus, }, secrets: []*corev1.Secret{ newSecret("testdata/good-jwk.json", "testdata/private-jwks.json"), }, wantGenerateKeyCount: 1, wantSecretActions: []kubetesting.Action{ kubetesting.NewGetAction(secretGVR, namespace, goodSecret.Name), kubetesting.NewUpdateAction(secretGVR, namespace, goodSecret), }, wantOPCActions: []kubetesting.Action{ kubetesting.NewGetAction(opcGVR, namespace, goodOPC.Name), }, }, { name: "invalid jwk key in secret", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPCWithStatus, }, secrets: []*corev1.Secret{ newSecret("testdata/invalid-key-jwk.json", "testdata/good-jwks.json"), }, wantGenerateKeyCount: 1, wantSecretActions: []kubetesting.Action{ kubetesting.NewGetAction(secretGVR, namespace, goodSecret.Name), kubetesting.NewUpdateAction(secretGVR, namespace, goodSecret), }, wantOPCActions: []kubetesting.Action{ kubetesting.NewGetAction(opcGVR, namespace, goodOPC.Name), }, }, { name: "invalid jwks key in secret", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPCWithStatus, }, secrets: []*corev1.Secret{ newSecret("testdata/good-jwk.json", "testdata/invalid-key-jwks.json"), }, wantGenerateKeyCount: 1, wantSecretActions: []kubetesting.Action{ kubetesting.NewGetAction(secretGVR, namespace, goodSecret.Name), kubetesting.NewUpdateAction(secretGVR, namespace, goodSecret), }, wantOPCActions: []kubetesting.Action{ kubetesting.NewGetAction(opcGVR, namespace, goodOPC.Name), }, }, { name: "missing active jwks in secret", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPCWithStatus, }, secrets: []*corev1.Secret{ newSecret("testdata/good-jwk.json", "testdata/missing-active-jwks.json"), }, wantGenerateKeyCount: 1, wantSecretActions: []kubetesting.Action{ kubetesting.NewGetAction(secretGVR, namespace, goodSecret.Name), kubetesting.NewUpdateAction(secretGVR, namespace, goodSecret), }, wantOPCActions: []kubetesting.Action{ kubetesting.NewGetAction(opcGVR, namespace, goodOPC.Name), }, }, { name: "generate key fails", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPCWithStatus, }, generateKeyErr: errors.New("some generate error"), wantError: "cannot generate secret: cannot generate key: some generate error", }, { name: "get secret fails", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPC, }, configKubeClient: func(client *kubernetesfake.Clientset) { client.PrependReactor("get", "secrets", func(_ kubetesting.Action) (bool, runtime.Object, error) { return true, nil, errors.New("some get error") }) }, wantError: "cannot create or update secret: cannot get secret: some get error", }, { name: "create secret fails", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPC, }, configKubeClient: func(client *kubernetesfake.Clientset) { client.PrependReactor("create", "secrets", func(_ kubetesting.Action) (bool, runtime.Object, error) { return true, nil, errors.New("some create error") }) }, wantError: "cannot create or update secret: cannot create secret: some create error", }, { name: "update secret fails", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPC, }, secrets: []*corev1.Secret{ newSecret("", ""), }, configKubeClient: func(client *kubernetesfake.Clientset) { client.PrependReactor("update", "secrets", func(_ kubetesting.Action) (bool, runtime.Object, error) { return true, nil, errors.New("some update error") }) }, wantError: "cannot create or update secret: some update error", }, { name: "get opc fails", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPC, }, configPinnipedClient: func(client *pinnipedfake.Clientset) { client.PrependReactor("get", "oidcproviderconfigs", func(_ kubetesting.Action) (bool, runtime.Object, error) { return true, nil, errors.New("some get error") }) }, wantError: "cannot update opc: cannot get opc: some get error", }, { name: "update opc fails", key: controllerlib.Key{Namespace: goodOPC.Namespace, Name: goodOPC.Name}, opcs: []*configv1alpha1.OIDCProviderConfig{ goodOPC, }, configPinnipedClient: func(client *pinnipedfake.Clientset) { client.PrependReactor("update", "oidcproviderconfigs", func(_ kubetesting.Action) (bool, runtime.Object, error) { return true, nil, errors.New("some update error") }) }, wantError: "cannot update opc: some update error", }, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { // We shouldn't run this test in parallel since it messes with a global function (generateKey). generateKeyCount := 0 generateKey = func(_ io.Reader, _ int) (interface{}, error) { generateKeyCount++ return goodRSAKey, test.generateKeyErr } ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) defer cancel() kubeAPIClient := kubernetesfake.NewSimpleClientset() kubeInformerClient := kubernetesfake.NewSimpleClientset() for _, secret := range test.secrets { require.NoError(t, kubeAPIClient.Tracker().Add(secret)) require.NoError(t, kubeInformerClient.Tracker().Add(secret)) } if test.configKubeClient != nil { test.configKubeClient(kubeAPIClient) } pinnipedAPIClient := pinnipedfake.NewSimpleClientset() pinnipedInformerClient := pinnipedfake.NewSimpleClientset() for _, opc := range test.opcs { require.NoError(t, pinnipedAPIClient.Tracker().Add(opc)) require.NoError(t, pinnipedInformerClient.Tracker().Add(opc)) } if test.configPinnipedClient != nil { test.configPinnipedClient(pinnipedAPIClient) } kubeInformers := kubeinformers.NewSharedInformerFactory( kubeInformerClient, 0, ) pinnipedInformers := pinnipedinformers.NewSharedInformerFactory( pinnipedInformerClient, 0, ) c := NewJWKSController( kubeAPIClient, pinnipedAPIClient, kubeInformers.Core().V1().Secrets(), pinnipedInformers.Config().V1alpha1().OIDCProviderConfigs(), controllerlib.WithInformer, ) // Must start informers before calling TestRunSynchronously(). kubeInformers.Start(ctx.Done()) pinnipedInformers.Start(ctx.Done()) controllerlib.TestRunSynchronously(t, c) err := controllerlib.TestSync(t, c, controllerlib.Context{ Context: ctx, Key: test.key, }) if test.wantError != "" { require.EqualError(t, err, test.wantError) return } require.NoError(t, err) require.Equal(t, test.wantGenerateKeyCount, generateKeyCount) if test.wantSecretActions != nil { require.Equal(t, test.wantSecretActions, kubeAPIClient.Actions()) } if test.wantOPCActions != nil { require.Equal(t, test.wantOPCActions, pinnipedAPIClient.Actions()) } }) } } func read(t *testing.T, path string) []byte { t.Helper() data, err := ioutil.ReadFile(path) require.NoError(t, err) // Trim whitespace from our testdata so that we match the compact JSON encoding of // our implementation. data = bytes.ReplaceAll(data, []byte(" "), []byte{}) data = bytes.ReplaceAll(data, []byte("\n"), []byte{}) return data } func boolPtr(b bool) *bool { return &b }