ContainerImage.Pinniped/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher_test.go

904 lines
39 KiB
Go
Raw Normal View History

// Copyright 2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package oidcclientwatcher
import (
"context"
"encoding/base32"
"fmt"
"strings"
"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"
kubeinformers "k8s.io/client-go/informers"
kubernetesfake "k8s.io/client-go/kubernetes/fake"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
pinnipedfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake"
pinnipedinformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions"
"go.pinniped.dev/internal/controllerlib"
"go.pinniped.dev/internal/testutil"
)
func TestOIDCClientWatcherControllerFilterSecret(t *testing.T) {
t.Parallel()
tests := []struct {
name string
secret metav1.Object
wantAdd bool
wantUpdate bool
wantDelete bool
}{
{
name: "a secret of the right type",
secret: &corev1.Secret{
Type: "storage.pinniped.dev/oidc-client-secret",
ObjectMeta: metav1.ObjectMeta{Name: "some-name", Namespace: "some-namespace"},
},
wantAdd: true,
wantUpdate: true,
wantDelete: true,
},
{
name: "a secret of the wrong type",
secret: &corev1.Secret{
Type: "secrets.pinniped.dev/some-other-type",
ObjectMeta: metav1.ObjectMeta{Name: "some-name", Namespace: "some-namespace"},
},
},
{
name: "resource of wrong data type",
secret: &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "some-name", Namespace: "some-namespace"},
},
},
}
for _, test := range tests {
tt := test
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
secretInformer := kubeinformers.NewSharedInformerFactory(
kubernetesfake.NewSimpleClientset(),
0,
).Core().V1().Secrets()
oidcClientsInformer := pinnipedinformers.NewSharedInformerFactory(
pinnipedfake.NewSimpleClientset(),
0,
).Config().V1alpha1().OIDCClients()
withInformer := testutil.NewObservableWithInformerOption()
_ = NewOIDCClientWatcherController(
nil, // pinnipedClient, not needed
secretInformer,
oidcClientsInformer,
withInformer.WithInformer,
)
unrelated := corev1.Secret{}
filter := withInformer.GetFilterForInformer(secretInformer)
require.Equal(t, tt.wantAdd, filter.Add(tt.secret))
require.Equal(t, tt.wantUpdate, filter.Update(&unrelated, tt.secret))
require.Equal(t, tt.wantUpdate, filter.Update(tt.secret, &unrelated))
require.Equal(t, tt.wantDelete, filter.Delete(tt.secret))
})
}
}
func TestOIDCClientWatcherControllerFilterOIDCClient(t *testing.T) {
t.Parallel()
tests := []struct {
name string
oidcClient configv1alpha1.OIDCClient
wantAdd bool
wantUpdate bool
wantDelete bool
}{
{
name: "anything goes",
oidcClient: configv1alpha1.OIDCClient{},
wantAdd: true,
wantUpdate: true,
wantDelete: true,
},
}
for _, test := range tests {
tt := test
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
secretInformer := kubeinformers.NewSharedInformerFactory(
kubernetesfake.NewSimpleClientset(),
0,
).Core().V1().Secrets()
oidcClientsInformer := pinnipedinformers.NewSharedInformerFactory(
pinnipedfake.NewSimpleClientset(),
0,
).Config().V1alpha1().OIDCClients()
withInformer := testutil.NewObservableWithInformerOption()
_ = NewOIDCClientWatcherController(
nil, // pinnipedClient, not needed
secretInformer,
oidcClientsInformer,
withInformer.WithInformer,
)
unrelated := configv1alpha1.OIDCClient{}
filter := withInformer.GetFilterForInformer(oidcClientsInformer)
require.Equal(t, tt.wantAdd, filter.Add(&tt.oidcClient))
require.Equal(t, tt.wantUpdate, filter.Update(&unrelated, &tt.oidcClient))
require.Equal(t, tt.wantUpdate, filter.Update(&tt.oidcClient, &unrelated))
require.Equal(t, tt.wantDelete, filter.Delete(&tt.oidcClient))
})
}
}
func TestOIDCClientWatcherControllerSync(t *testing.T) {
t.Parallel()
const (
testName = "test-name"
testNamespace = "test-namespace"
testUID = "test-uid-123"
//nolint:gosec // this is not a credential
testBcryptSecret1 = "$2y$15$Kh7cRj0ScSD5QelE3ZNSl.nF04JDv7zb3SgGN.tSfLIX.4kt3UX7m" // bcrypt of "password1"
//nolint:gosec // this is not a credential
testBcryptSecret2 = "$2y$15$Kh7cRj0ScSD5QelE3ZNSl.nF04JDv7zb3SgGN.tSfLIX.4kt3UX7m" // bcrypt of "password2"
)
now := metav1.NewTime(time.Now().UTC())
earlier := metav1.NewTime(now.Add(-1 * time.Hour).UTC())
happyAllowedGrantTypesCondition := func(time metav1.Time, observedGeneration int64) configv1alpha1.Condition {
return configv1alpha1.Condition{
Type: "AllowedGrantTypesValid",
Status: "True",
LastTransitionTime: time,
Reason: "Success",
Message: `"allowedGrantTypes" is valid`,
ObservedGeneration: observedGeneration,
}
}
sadAllowedGrantTypesCondition := func(time metav1.Time, observedGeneration int64, message string) configv1alpha1.Condition {
return configv1alpha1.Condition{
Type: "AllowedGrantTypesValid",
Status: "False",
LastTransitionTime: time,
Reason: "MissingRequiredValue",
Message: message,
ObservedGeneration: observedGeneration,
}
}
happyClientSecretsCondition := func(howMany int, time metav1.Time, observedGeneration int64) configv1alpha1.Condition {
return configv1alpha1.Condition{
Type: "ClientSecretExists",
Status: "True",
LastTransitionTime: time,
Reason: "Success",
Message: fmt.Sprintf(`%d client secret(s) found`, howMany),
ObservedGeneration: observedGeneration,
}
}
sadClientSecretsCondition := func(time metav1.Time, observedGeneration int64, message string) configv1alpha1.Condition {
return configv1alpha1.Condition{
Type: "ClientSecretExists",
Status: "False",
LastTransitionTime: time,
Reason: "NoClientSecretFound",
Message: message,
ObservedGeneration: observedGeneration,
}
}
happyAllowedScopesCondition := func(time metav1.Time, observedGeneration int64) configv1alpha1.Condition {
return configv1alpha1.Condition{
Type: "AllowedScopesValid",
Status: "True",
LastTransitionTime: time,
Reason: "Success",
Message: `"allowedScopes" is valid`,
ObservedGeneration: observedGeneration,
}
}
sadAllowedScopesCondition := func(time metav1.Time, observedGeneration int64, message string) configv1alpha1.Condition {
return configv1alpha1.Condition{
Type: "AllowedScopesValid",
Status: "False",
LastTransitionTime: time,
Reason: "MissingRequiredValue",
Message: message,
ObservedGeneration: observedGeneration,
}
}
secretNameForUID := func(uid string) string {
// See GetName() in OIDCClientSecretStorage for how the production code determines the Secret name.
// This test helper is intended to choose the same name.
return "pinniped-storage-oidc-client-secret-" +
strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(uid)))
}
secretStringDataWithZeroClientSecrets := map[string][]byte{
"pinniped-storage-data": []byte(`{"version":"1","hashes":[]}`),
"pinniped-storage-version": []byte("1"),
}
secretStringDataWithOneClientSecret := map[string][]byte{
"pinniped-storage-data": []byte(`{"version":"1","hashes":["` + testBcryptSecret1 + `"]}`),
"pinniped-storage-version": []byte("1"),
}
secretStringDataWithTwoClientSecrets := map[string][]byte{
"pinniped-storage-data": []byte(`{"version":"1","hashes":["` + testBcryptSecret1 + `","` + testBcryptSecret2 + `"]}`),
"pinniped-storage-version": []byte("1"),
}
secretStringDataWithWrongVersion := map[string][]byte{
"pinniped-storage-data": []byte(`{"version":"wrong-version","hashes":[]}`),
"pinniped-storage-version": []byte("1"),
}
storageSecretForUIDWithData := func(uid string, data map[string][]byte) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: testNamespace,
Name: secretNameForUID(uid),
Labels: map[string]string{"storage.pinniped.dev/type": "oidc-client-secret"},
},
Type: "storage.pinniped.dev/oidc-client-secret",
Data: data,
}
}
tests := []struct {
name string
inputObjects []runtime.Object
inputSecrets []runtime.Object
wantErr string
wantResultingOIDCClients []configv1alpha1.OIDCClient
wantAPIActions int
}{
{
name: "no OIDCClients",
wantAPIActions: 0, // no updates
},
{
name: "successfully validate minimal OIDCClient and one client secret stored",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code"},
AllowedScopes: []configv1alpha1.Scope{"openid"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Ready",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
happyAllowedScopesCondition(now, 1234),
happyClientSecretsCondition(1, now, 1234),
},
},
}},
},
{
name: "successfully validate minimal OIDCClient and two client secrets stored",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code"},
AllowedScopes: []configv1alpha1.Scope{"openid"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithTwoClientSecrets)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Ready",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
happyAllowedScopesCondition(now, 1234),
happyClientSecretsCondition(2, now, 1234),
},
},
}},
},
{
name: "an already validated OIDCClient does not have its conditions updated when everything is still valid",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code"},
AllowedScopes: []configv1alpha1.Scope{"openid"},
},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Ready",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(earlier, 1234),
happyAllowedScopesCondition(earlier, 1234),
happyClientSecretsCondition(1, earlier, 1234),
},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 0, // no updates
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Ready",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(earlier, 1234),
happyAllowedScopesCondition(earlier, 1234),
happyClientSecretsCondition(1, earlier, 1234),
},
},
}},
},
{
name: "missing required minimum settings and missing client secret storage",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{},
}},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Error",
Conditions: []configv1alpha1.Condition{
sadAllowedGrantTypesCondition(now, 1234, `"authorization_code" must always be included in "allowedGrantTypes"`),
sadAllowedScopesCondition(now, 1234, `"openid" must always be included in "allowedScopes"`),
sadClientSecretsCondition(now, 1234, "no client secret found (no Secret storage found)"),
},
},
}},
},
{
name: "client secret storage exists but cannot be read",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code"},
AllowedScopes: []configv1alpha1.Scope{"openid"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithWrongVersion)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Error",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
happyAllowedScopesCondition(now, 1234),
sadClientSecretsCondition(now, 1234, "error reading client secret storage: OIDC client secret storage data has wrong version: OIDC client secret storage has version wrong-version instead of 1"),
},
},
}},
},
{
name: "client secret storage exists but does not contain any client secrets",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code"},
AllowedScopes: []configv1alpha1.Scope{"openid"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithZeroClientSecrets)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Error",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
happyAllowedScopesCondition(now, 1234),
sadClientSecretsCondition(now, 1234, "no client secret found (empty list in storage)"),
},
},
}},
},
{
name: "can operate on multiple at a time, e.g. one is valid one another is missing required minimum settings",
inputObjects: []runtime.Object{
&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "test1", Generation: 1234, UID: "uid1"},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code"},
AllowedScopes: []configv1alpha1.Scope{"openid"},
},
},
&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "test2", Generation: 4567, UID: "uid2"},
Spec: configv1alpha1.OIDCClientSpec{},
},
},
inputSecrets: []runtime.Object{storageSecretForUIDWithData("uid1", secretStringDataWithOneClientSecret)},
wantAPIActions: 2, // one update for each OIDCClient
wantResultingOIDCClients: []configv1alpha1.OIDCClient{
{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "test1", Generation: 1234, UID: "uid1"},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Ready",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
happyAllowedScopesCondition(now, 1234),
happyClientSecretsCondition(1, now, 1234),
},
},
},
{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "test2", Generation: 4567, UID: "uid2"},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Error",
Conditions: []configv1alpha1.Condition{
sadAllowedGrantTypesCondition(now, 4567, `"authorization_code" must always be included in "allowedGrantTypes"`),
sadAllowedScopesCondition(now, 4567, `"openid" must always be included in "allowedScopes"`),
sadClientSecretsCondition(now, 4567, "no client secret found (no Secret storage found)"),
},
},
},
},
},
{
name: "a previously invalid OIDCClient has its spec changed to become valid so the conditions are updated",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 4567, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code"},
AllowedScopes: []configv1alpha1.Scope{"openid"},
},
// was invalid on previous run of controller which observed an old generation at an earlier time
Status: configv1alpha1.OIDCClientStatus{
Phase: "Error",
Conditions: []configv1alpha1.Condition{
sadAllowedGrantTypesCondition(earlier, 1234, `"authorization_code" must always be included in "allowedGrantTypes"`),
sadAllowedScopesCondition(earlier, 1234, `"openid" must always be included in "allowedScopes"`),
happyClientSecretsCondition(1, earlier, 1234),
},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 4567, UID: testUID},
// status was updated to reflect the current generation at the current time
Status: configv1alpha1.OIDCClientStatus{
Phase: "Ready",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 4567),
happyAllowedScopesCondition(now, 4567),
happyClientSecretsCondition(1, earlier, 4567), // was already validated earlier
},
},
}},
},
{
name: "refresh_token must be included in allowedGrantTypes when offline_access is included in allowedScopes",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code"},
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access"},
},
}},
wantAPIActions: 1, // one update
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Error",
Conditions: []configv1alpha1.Condition{
sadAllowedGrantTypesCondition(now, 1234, `"refresh_token" must be included in "allowedGrantTypes" when "offline_access" is included in "allowedScopes"`),
happyAllowedScopesCondition(now, 1234),
happyClientSecretsCondition(1, now, 1234),
},
},
}},
},
{
name: "urn:ietf:params:oauth:grant-type:token-exchange must be included in allowedGrantTypes when pinniped:request-audience is included in allowedScopes",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code"},
AllowedScopes: []configv1alpha1.Scope{"openid", "pinniped:request-audience", "username", "groups"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Error",
Conditions: []configv1alpha1.Condition{
sadAllowedGrantTypesCondition(now, 1234, `"urn:ietf:params:oauth:grant-type:token-exchange" must be included in "allowedGrantTypes" when "pinniped:request-audience" is included in "allowedScopes"`),
happyAllowedScopesCondition(now, 1234),
happyClientSecretsCondition(1, now, 1234),
},
},
}},
},
{
name: "offline_access must be included in allowedScopes when refresh_token is included in allowedGrantTypes",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"},
AllowedScopes: []configv1alpha1.Scope{"openid"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Error",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
sadAllowedScopesCondition(now, 1234, `"offline_access" must be included in "allowedScopes" when "refresh_token" is included in "allowedGrantTypes"`),
happyClientSecretsCondition(1, now, 1234),
},
},
}},
},
{
name: "username and groups must also be included in allowedScopes when pinniped:request-audience is included in allowedScopes: both missing",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange"},
AllowedScopes: []configv1alpha1.Scope{"openid", "pinniped:request-audience"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Error",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
sadAllowedScopesCondition(now, 1234, `"username" and "groups" must be included in "allowedScopes" when "pinniped:request-audience" is included in "allowedScopes"`),
happyClientSecretsCondition(1, now, 1234),
},
},
}},
},
{
name: "username and groups must also be included in allowedScopes when pinniped:request-audience is included in allowedScopes: username missing",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange"},
AllowedScopes: []configv1alpha1.Scope{"openid", "pinniped:request-audience", "groups"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Error",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
sadAllowedScopesCondition(now, 1234, `"username" and "groups" must be included in "allowedScopes" when "pinniped:request-audience" is included in "allowedScopes"`),
happyClientSecretsCondition(1, now, 1234),
},
},
}},
},
{
name: "username and groups must also be included in allowedScopes when pinniped:request-audience is included in allowedScopes: groups missing",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange"},
AllowedScopes: []configv1alpha1.Scope{"openid", "pinniped:request-audience", "username"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Error",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
sadAllowedScopesCondition(now, 1234, `"username" and "groups" must be included in "allowedScopes" when "pinniped:request-audience" is included in "allowedScopes"`),
happyClientSecretsCondition(1, now, 1234),
},
},
}},
},
{
name: "pinniped:request-audience must be included in allowedScopes when urn:ietf:params:oauth:grant-type:token-exchange is included in allowedGrantTypes",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange"},
AllowedScopes: []configv1alpha1.Scope{"openid"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Error",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
sadAllowedScopesCondition(now, 1234, `"pinniped:request-audience" must be included in "allowedScopes" when "urn:ietf:params:oauth:grant-type:token-exchange" is included in "allowedGrantTypes"`),
happyClientSecretsCondition(1, now, 1234),
},
},
}},
},
{
name: "successfully validate an OIDCClient with all allowedGrantTypes and all allowedScopes",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"},
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Ready",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
happyAllowedScopesCondition(now, 1234),
happyClientSecretsCondition(1, now, 1234),
},
},
}},
},
{
name: "successfully validate an OIDCClient for offline access without kube API access without username/groups",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"},
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Ready",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
happyAllowedScopesCondition(now, 1234),
happyClientSecretsCondition(1, now, 1234),
},
},
}},
},
{
name: "successfully validate an OIDCClient for offline access without kube API access with username",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"},
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "username"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Ready",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
happyAllowedScopesCondition(now, 1234),
happyClientSecretsCondition(1, now, 1234),
},
},
}},
},
{
name: "successfully validate an OIDCClient for offline access without kube API access with groups",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"},
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "groups"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Ready",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
happyAllowedScopesCondition(now, 1234),
happyClientSecretsCondition(1, now, 1234),
},
},
}},
},
{
name: "successfully validate an OIDCClient for offline access without kube API access with both username and groups",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"},
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "username", "groups"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Ready",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
happyAllowedScopesCondition(now, 1234),
happyClientSecretsCondition(1, now, 1234),
},
},
}},
},
{
name: "successfully validate an OIDCClient without offline access without kube API access with username",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code"},
AllowedScopes: []configv1alpha1.Scope{"openid", "username"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Ready",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
happyAllowedScopesCondition(now, 1234),
happyClientSecretsCondition(1, now, 1234),
},
},
}},
},
{
name: "successfully validate an OIDCClient without offline access without kube API access with groups",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code"},
AllowedScopes: []configv1alpha1.Scope{"openid", "username"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Ready",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
happyAllowedScopesCondition(now, 1234),
happyClientSecretsCondition(1, now, 1234),
},
},
}},
},
{
name: "successfully validate an OIDCClient without offline access without kube API access with both username and groups",
inputObjects: []runtime.Object{&configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code"},
AllowedScopes: []configv1alpha1.Scope{"openid", "username", "groups"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
Phase: "Ready",
Conditions: []configv1alpha1.Condition{
happyAllowedGrantTypesCondition(now, 1234),
happyAllowedScopesCondition(now, 1234),
happyClientSecretsCondition(1, now, 1234),
},
},
}},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fakePinnipedClient := pinnipedfake.NewSimpleClientset(tt.inputObjects...)
fakePinnipedClientForInformers := pinnipedfake.NewSimpleClientset(tt.inputObjects...)
pinnipedInformers := pinnipedinformers.NewSharedInformerFactory(fakePinnipedClientForInformers, 0)
fakeKubeClient := kubernetesfake.NewSimpleClientset(tt.inputSecrets...)
kubeInformers := kubeinformers.NewSharedInformerFactoryWithOptions(fakeKubeClient, 0)
controller := NewOIDCClientWatcherController(
fakePinnipedClient,
kubeInformers.Core().V1().Secrets(),
pinnipedInformers.Config().V1alpha1().OIDCClients(),
controllerlib.WithInformer,
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pinnipedInformers.Start(ctx.Done())
kubeInformers.Start(ctx.Done())
controllerlib.TestRunSynchronously(t, controller)
syncCtx := controllerlib.Context{Context: ctx, Key: controllerlib.Key{}}
if err := controllerlib.TestSync(t, controller, syncCtx); tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
require.Len(t, fakePinnipedClient.Actions(), tt.wantAPIActions)
actualOIDCClients, err := fakePinnipedClient.ConfigV1alpha1().OIDCClients(testNamespace).List(ctx, metav1.ListOptions{})
require.NoError(t, err)
// Assert on the expected Status of the OIDCClients. Preprocess them a bit so that they're easier to assert against.
require.ElementsMatch(t, tt.wantResultingOIDCClients, normalizeOIDCClients(actualOIDCClients.Items, now))
})
}
}
func normalizeOIDCClients(oidcClients []configv1alpha1.OIDCClient, now metav1.Time) []configv1alpha1.OIDCClient {
result := make([]configv1alpha1.OIDCClient, 0, len(oidcClients))
for _, u := range oidcClients {
normalized := u.DeepCopy()
// We're only interested in comparing the status, so zero out the spec.
normalized.Spec = configv1alpha1.OIDCClientSpec{}
// Round down the LastTransitionTime values to `now` if they were just updated. This makes
// it much easier to encode assertions about the expected timestamps.
for i := range normalized.Status.Conditions {
if time.Since(normalized.Status.Conditions[i].LastTransitionTime.Time) < 5*time.Second {
normalized.Status.Conditions[i].LastTransitionTime = now
}
}
result = append(result, *normalized)
}
return result
}