ContainerImage.Pinniped/internal/controller/supervisorconfig/jwks_writer_test.go

710 lines
22 KiB
Go
Raw Normal View History

// 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/supervisor/config/v1alpha1"
pinnipedfake "go.pinniped.dev/generated/1.19/client/supervisor/clientset/versioned/fake"
pinnipedinformers "go.pinniped.dev/generated/1.19/client/supervisor/informers/externalversions"
"go.pinniped.dev/internal/controllerlib"
"go.pinniped.dev/internal/testutil"
)
func TestJWKSWriterControllerFilterSecret(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: "OIDCProvider",
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: "OIDCProvider",
Name: "some-name",
},
},
},
},
},
{
name: "correct owner reference",
secret: corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: "some-namespace",
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: configv1alpha1.SchemeGroupVersion.String(),
Kind: "OIDCProvider",
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: "OIDCProvider",
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().OIDCProviders()
withInformer := testutil.NewObservableWithInformerOption()
_ = NewJWKSWriterController(
nil, // labels, not needed
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 TestJWKSWriterControllerFilterOPC(t *testing.T) {
t.Parallel()
tests := []struct {
name string
opc configv1alpha1.OIDCProvider
wantAdd bool
wantUpdate bool
wantDelete bool
wantParent controllerlib.Key
}{
{
name: "anything goes",
opc: configv1alpha1.OIDCProvider{},
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().OIDCProviders()
withInformer := testutil.NewObservableWithInformerOption()
_ = NewJWKSWriterController(
nil, // labels, not needed
nil, // kubeClient, not needed
nil, // pinnipedClient, not needed
secretInformer,
opcInformer,
withInformer.WithInformer,
)
unrelated := configv1alpha1.OIDCProvider{}
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 TestJWKSWriterControllerSync(t *testing.T) {
// We shouldn't run this test in parallel since it messes with a global function (generateKey).
const namespace = "tuna-namespace"
goodKeyPEM, err := ioutil.ReadFile("testdata/good-ec-key.pem")
require.NoError(t, err)
block, _ := pem.Decode(goodKeyPEM)
require.NotNil(t, block, "expected block to be non-nil...is goodKeyPEM a valid PEM?")
goodKey, err := x509.ParseECPrivateKey(block.Bytes)
require.NoError(t, err)
opcGVR := schema.GroupVersionResource{
Group: configv1alpha1.SchemeGroupVersion.Group,
Version: configv1alpha1.SchemeGroupVersion.Version,
Resource: "oidcproviders",
}
goodOPC := &configv1alpha1.OIDCProvider{
ObjectMeta: metav1.ObjectMeta{
Name: "good-opc",
Namespace: namespace,
UID: "good-opc-uid",
},
Spec: configv1alpha1.OIDCProviderSpec{
Issuer: "https://some-issuer.com",
},
}
goodOPCWithStatus := goodOPC.DeepCopy()
goodOPCWithStatus.Status.Secrets.JWKS.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.Secrets.JWKS.Name,
Namespace: namespace,
Labels: map[string]string{
"myLabelKey1": "myLabelValue1",
"myLabelKey2": "myLabelValue2",
},
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: opcGVR.GroupVersion().String(),
Kind: "OIDCProvider",
Name: goodOPC.Name,
UID: goodOPC.UID,
BlockOwnerDeletion: boolPtr(true),
Controller: boolPtr(true),
},
},
},
}
s.Data = make(map[string][]byte)
if activeJWKPath != "" {
s.Data["activeJWK"] = readJWKJSON(t, activeJWKPath)
}
if jwksPath != "" {
s.Data["jwks"] = readJWKJSON(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.OIDCProvider
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.OIDCProvider{
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.OIDCProvider{
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.OIDCProvider{
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.OIDCProvider{
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.OIDCProvider{
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.OIDCProvider{
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.OIDCProvider{
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.OIDCProvider{
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.OIDCProvider{
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.OIDCProvider{
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.OIDCProvider{
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.OIDCProvider{
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.OIDCProvider{
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.OIDCProvider{
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.OIDCProvider{
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.OIDCProvider{
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.OIDCProvider{
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.OIDCProvider{
goodOPC,
},
configPinnipedClient: func(client *pinnipedfake.Clientset) {
client.PrependReactor("get", "oidcproviders", 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.OIDCProvider{
goodOPC,
},
configPinnipedClient: func(client *pinnipedfake.Clientset) {
client.PrependReactor("update", "oidcproviders", 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) (interface{}, error) {
generateKeyCount++
return goodKey, 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 := NewJWKSWriterController(
map[string]string{
"myLabelKey1": "myLabelValue1",
"myLabelKey2": "myLabelValue2",
},
kubeAPIClient,
pinnipedAPIClient,
kubeInformers.Core().V1().Secrets(),
pinnipedInformers.Config().V1alpha1().OIDCProviders(),
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 readJWKJSON(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 }