// Copyright 2022-2026 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package oidcclientwatcher

import (
	"context"
	"fmt"
	"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"
	k8sinformers "k8s.io/client-go/informers"
	kubefake "k8s.io/client-go/kubernetes/fake"

	supervisorconfigv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
	supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake"
	supervisorinformers "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 := k8sinformers.NewSharedInformerFactory(
				kubefake.NewClientset(),
				0,
			).Core().V1().Secrets()
			oidcClientsInformer := supervisorinformers.NewSharedInformerFactory(
				supervisorfake.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 supervisorconfigv1alpha1.OIDCClient
		wantAdd    bool
		wantUpdate bool
		wantDelete bool
	}{
		{
			name: "name has client.oauth.pinniped.dev- prefix",
			oidcClient: supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Name: "client.oauth.pinniped.dev-foo"},
			},
			wantAdd:    true,
			wantUpdate: true,
			wantDelete: true,
		},
		{
			name: "name does not have client.oauth.pinniped.dev- prefix",
			oidcClient: supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Name: "something.oauth.pinniped.dev-foo"},
			},
			wantAdd:    false,
			wantUpdate: false,
			wantDelete: false,
		},
		{
			name: "other names without any particular pinniped.dev prefixes",
			oidcClient: supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Name: "something"},
			},
			wantAdd:    false,
			wantUpdate: false,
			wantDelete: false,
		},
	}
	for _, test := range tests {
		tt := test
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			secretInformer := k8sinformers.NewSharedInformerFactory(
				kubefake.NewClientset(),
				0,
			).Core().V1().Secrets()
			oidcClientsInformer := supervisorinformers.NewSharedInformerFactory(
				supervisorfake.NewSimpleClientset(),
				0,
			).Config().V1alpha1().OIDCClients()
			withInformer := testutil.NewObservableWithInformerOption()
			_ = NewOIDCClientWatcherController(
				nil, // pinnipedClient, not needed
				secretInformer,
				oidcClientsInformer,
				withInformer.WithInformer,
			)

			unrelated := supervisorconfigv1alpha1.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      = "client.oauth.pinniped.dev-test-name"
		testNamespace = "test-namespace"
		testUID       = "test-uid-123"
	)

	now := metav1.NewTime(time.Now().UTC())
	earlier := metav1.NewTime(now.Add(-1 * time.Hour).UTC())

	happyAllowedGrantTypesCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition {
		return metav1.Condition{
			Type:               "AllowedGrantTypesValid",
			Status:             "True",
			LastTransitionTime: time,
			Reason:             "Success",
			Message:            `"allowedGrantTypes" is valid`,
			ObservedGeneration: observedGeneration,
		}
	}

	sadAllowedGrantTypesCondition := func(time metav1.Time, observedGeneration int64, message string) metav1.Condition {
		return metav1.Condition{
			Type:               "AllowedGrantTypesValid",
			Status:             "False",
			LastTransitionTime: time,
			Reason:             "MissingRequiredValue",
			Message:            message,
			ObservedGeneration: observedGeneration,
		}
	}

	happyClientSecretsCondition := func(howMany int, time metav1.Time, observedGeneration int64) metav1.Condition {
		return metav1.Condition{
			Type:               "ClientSecretExists",
			Status:             "True",
			LastTransitionTime: time,
			Reason:             "Success",
			Message:            fmt.Sprintf(`%d client secret(s) found`, howMany),
			ObservedGeneration: observedGeneration,
		}
	}

	sadNoClientSecretsCondition := func(time metav1.Time, observedGeneration int64, message string) metav1.Condition {
		return metav1.Condition{
			Type:               "ClientSecretExists",
			Status:             "False",
			LastTransitionTime: time,
			Reason:             "NoClientSecretFound",
			Message:            message,
			ObservedGeneration: observedGeneration,
		}
	}

	sadInvalidClientSecretsCondition := func(time metav1.Time, observedGeneration int64, message string) metav1.Condition {
		return metav1.Condition{
			Type:               "ClientSecretExists",
			Status:             "False",
			LastTransitionTime: time,
			Reason:             "InvalidClientSecretFound",
			Message:            message,
			ObservedGeneration: observedGeneration,
		}
	}

	happyAllowedScopesCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition {
		return metav1.Condition{
			Type:               "AllowedScopesValid",
			Status:             "True",
			LastTransitionTime: time,
			Reason:             "Success",
			Message:            `"allowedScopes" is valid`,
			ObservedGeneration: observedGeneration,
		}
	}

	sadAllowedScopesCondition := func(time metav1.Time, observedGeneration int64, message string) metav1.Condition {
		return metav1.Condition{
			Type:               "AllowedScopesValid",
			Status:             "False",
			LastTransitionTime: time,
			Reason:             "MissingRequiredValue",
			Message:            message,
			ObservedGeneration: observedGeneration,
		}
	}

	tests := []struct {
		name                     string
		inputObjects             []runtime.Object
		inputSecrets             []runtime.Object
		wantErr                  string
		wantResultingOIDCClients []supervisorconfigv1alpha1.OIDCClient
		wantAPIActions           int
	}{
		{
			name:           "no OIDCClients",
			wantAPIActions: 0, // no updates
		},
		{
			name: "OIDCClient with wrong prefix is ignored",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "wrong-prefix-name", Generation: 1234, UID: testUID},
			}},
			wantAPIActions: 0, // no updates
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "wrong-prefix-name", Generation: 1234, UID: testUID},
			}},
		},
		{
			name: "successfully validate minimal OIDCClient and one client secret stored (while ignoring client with wrong prefix)",
			inputObjects: []runtime.Object{
				&supervisorconfigv1alpha1.OIDCClient{
					ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "wrong-prefix-name", Generation: 1234, UID: testUID},
				},
				&supervisorconfigv1alpha1.OIDCClient{
					ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
					Spec: supervisorconfigv1alpha1.OIDCClientSpec{
						AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code"},
						AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid"},
					},
				},
			},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{
				{
					ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "wrong-prefix-name", Generation: 1234, UID: testUID},
				},
				{
					ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
					Status: supervisorconfigv1alpha1.OIDCClientStatus{
						Phase: "Ready",
						Conditions: []metav1.Condition{
							happyAllowedGrantTypesCondition(now, 1234),
							happyAllowedScopesCondition(now, 1234),
							happyClientSecretsCondition(1, now, 1234),
						},
						TotalClientSecrets: 1,
					},
				},
			},
		},
		{
			name: "successfully validate minimal OIDCClient and two client secrets stored",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost, testutil.HashedPassword2AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Ready",
					Conditions: []metav1.Condition{
						happyAllowedGrantTypesCondition(now, 1234),
						happyAllowedScopesCondition(now, 1234),
						happyClientSecretsCondition(2, now, 1234),
					},
					TotalClientSecrets: 2,
				},
			}},
		},
		{
			name: "an already validated OIDCClient does not have its conditions updated when everything is still valid",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid"},
				},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Ready",
					Conditions: []metav1.Condition{
						happyAllowedGrantTypesCondition(earlier, 1234),
						happyAllowedScopesCondition(earlier, 1234),
						happyClientSecretsCondition(1, earlier, 1234),
					},
					TotalClientSecrets: 1,
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 0, // no updates
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Ready",
					Conditions: []metav1.Condition{
						happyAllowedGrantTypesCondition(earlier, 1234),
						happyAllowedScopesCondition(earlier, 1234),
						happyClientSecretsCondition(1, earlier, 1234),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			name: "missing required minimum settings and missing client secret storage",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec:       supervisorconfigv1alpha1.OIDCClientSpec{},
			}},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Error",
					Conditions: []metav1.Condition{
						sadAllowedGrantTypesCondition(now, 1234, `"authorization_code" must always be included in "allowedGrantTypes"`),
						sadAllowedScopesCondition(now, 1234, `"openid" must always be included in "allowedScopes"`),
						sadNoClientSecretsCondition(now, 1234, "no client secret found (no Secret storage found)"),
					},
				},
			}},
		},
		{
			name: "client secret storage exists but cannot be read",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUIDWithWrongVersion(t, testNamespace, testUID)},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Error",
					Conditions: []metav1.Condition{
						happyAllowedGrantTypesCondition(now, 1234),
						happyAllowedScopesCondition(now, 1234),
						sadNoClientSecretsCondition(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{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Error",
					Conditions: []metav1.Condition{
						happyAllowedGrantTypesCondition(now, 1234),
						happyAllowedScopesCondition(now, 1234),
						sadNoClientSecretsCondition(now, 1234, "no client secret found (empty list in storage)"),
					},
					TotalClientSecrets: 0,
				},
			}},
		},
		{
			name: "client secret storage exists but some of the client secrets are invalid bcrypt hashes",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid"},
				},
			}},
			inputSecrets: []runtime.Object{
				testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID,
					[]string{testutil.HashedPassword1AtSupervisorMinCost, testutil.HashedPassword1JustBelowSupervisorMinCost, testutil.HashedPassword1InvalidFormat}),
			},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Error",
					Conditions: []metav1.Condition{
						happyAllowedGrantTypesCondition(now, 1234),
						happyAllowedScopesCondition(now, 1234),
						sadInvalidClientSecretsCondition(now, 1234,
							"3 stored client secrets found, but some were invalid, so none will be used: "+
								"hashed client secret at index 1: bcrypt cost 11 is below the required minimum of 12; "+
								"hashed client secret at index 2: crypto/bcrypt: hashedSecret too short to be a bcrypted password"),
					},
					TotalClientSecrets: 0,
				},
			}},
		},
		{
			name: "can operate on multiple at a time, e.g. one is valid one another is missing required minimum settings",
			inputObjects: []runtime.Object{
				&supervisorconfigv1alpha1.OIDCClient{
					ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "client.oauth.pinniped.dev-test1", Generation: 1234, UID: "uid1"},
					Spec: supervisorconfigv1alpha1.OIDCClientSpec{
						AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code"},
						AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid"},
					},
				},
				&supervisorconfigv1alpha1.OIDCClient{
					ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "client.oauth.pinniped.dev-test2", Generation: 4567, UID: "uid2"},
					Spec:       supervisorconfigv1alpha1.OIDCClientSpec{},
				},
			},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, "uid1", []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 2, // one update for each OIDCClient
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{
				{
					ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "client.oauth.pinniped.dev-test1", Generation: 1234, UID: "uid1"},
					Status: supervisorconfigv1alpha1.OIDCClientStatus{
						Phase: "Ready",
						Conditions: []metav1.Condition{
							happyAllowedGrantTypesCondition(now, 1234),
							happyAllowedScopesCondition(now, 1234),
							happyClientSecretsCondition(1, now, 1234),
						},
						TotalClientSecrets: 1,
					},
				},
				{
					ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "client.oauth.pinniped.dev-test2", Generation: 4567, UID: "uid2"},
					Status: supervisorconfigv1alpha1.OIDCClientStatus{
						Phase: "Error",
						Conditions: []metav1.Condition{
							sadAllowedGrantTypesCondition(now, 4567, `"authorization_code" must always be included in "allowedGrantTypes"`),
							sadAllowedScopesCondition(now, 4567, `"openid" must always be included in "allowedScopes"`),
							sadNoClientSecretsCondition(now, 4567, "no client secret found (no Secret storage found)"),
						},
						TotalClientSecrets: 0,
					},
				},
			},
		},
		{
			name: "a previously invalid OIDCClient has its spec changed to become valid so the conditions are updated",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 4567, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid"},
				},
				// was invalid on previous run of controller which observed an old generation at an earlier time
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Error",
					Conditions: []metav1.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),
					},
					TotalClientSecrets: 1,
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.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: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Ready",
					Conditions: []metav1.Condition{
						happyAllowedGrantTypesCondition(now, 4567),
						happyAllowedScopesCondition(now, 4567),
						happyClientSecretsCondition(1, earlier, 4567), // was already validated earlier
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			name: "refresh_token must be included in allowedGrantTypes when offline_access is included in allowedScopes",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid", "offline_access"},
				},
			}},
			wantAPIActions: 1, // one update
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Error",
					Conditions: []metav1.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),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			name: "multiple errors on allowedScopes and allowedGrantTypes",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"refresh_token"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"pinniped:request-audience"},
				},
			}},
			wantAPIActions: 1, // one update
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Error",
					Conditions: []metav1.Condition{
						sadAllowedGrantTypesCondition(now, 1234,
							`"authorization_code" must always be included in "allowedGrantTypes"; `+
								`"urn:ietf:params:oauth:grant-type:token-exchange" must be included in "allowedGrantTypes" when "pinniped:request-audience" is included in "allowedScopes"`),
						sadAllowedScopesCondition(now, 1234,
							`"openid" must always be included in "allowedScopes"; `+
								`"offline_access" must be included in "allowedScopes" when "refresh_token" is included in "allowedGrantTypes"; `+
								`"username" and "groups" must be included in "allowedScopes" when "pinniped:request-audience" is included in "allowedScopes"`),
						happyClientSecretsCondition(1, now, 1234),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			name: "another combination of multiple errors on allowedScopes and allowedGrantTypes",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"urn:ietf:params:oauth:grant-type:token-exchange"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"offline_access"},
				},
			}},
			wantAPIActions: 1, // one update
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Error",
					Conditions: []metav1.Condition{
						sadAllowedGrantTypesCondition(now, 1234,
							`"authorization_code" must always be included in "allowedGrantTypes"; `+
								`"refresh_token" must be included in "allowedGrantTypes" when "offline_access" is included in "allowedScopes"`),
						sadAllowedScopesCondition(now, 1234,
							`"openid" must always be included in "allowedScopes"; `+
								`"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),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			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{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid", "pinniped:request-audience", "username", "groups"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Error",
					Conditions: []metav1.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),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			name: "offline_access must be included in allowedScopes when refresh_token is included in allowedGrantTypes",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code", "refresh_token"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Error",
					Conditions: []metav1.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),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			name: "username and groups must also be included in allowedScopes when pinniped:request-audience is included in allowedScopes: both missing",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid", "pinniped:request-audience"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Error",
					Conditions: []metav1.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),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			name: "username and groups must also be included in allowedScopes when pinniped:request-audience is included in allowedScopes: username missing",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid", "pinniped:request-audience", "groups"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Error",
					Conditions: []metav1.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),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			name: "username and groups must also be included in allowedScopes when pinniped:request-audience is included in allowedScopes: groups missing",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid", "pinniped:request-audience", "username"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Error",
					Conditions: []metav1.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),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			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{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Error",
					Conditions: []metav1.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),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			name: "successfully validate an OIDCClient with all allowedGrantTypes and all allowedScopes",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Ready",
					Conditions: []metav1.Condition{
						happyAllowedGrantTypesCondition(now, 1234),
						happyAllowedScopesCondition(now, 1234),
						happyClientSecretsCondition(1, now, 1234),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			name: "successfully validate an OIDCClient for offline access without kube API access without username/groups",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code", "refresh_token"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid", "offline_access"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Ready",
					Conditions: []metav1.Condition{
						happyAllowedGrantTypesCondition(now, 1234),
						happyAllowedScopesCondition(now, 1234),
						happyClientSecretsCondition(1, now, 1234),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			name: "successfully validate an OIDCClient for offline access without kube API access with username",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code", "refresh_token"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid", "offline_access", "username"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Ready",
					Conditions: []metav1.Condition{
						happyAllowedGrantTypesCondition(now, 1234),
						happyAllowedScopesCondition(now, 1234),
						happyClientSecretsCondition(1, now, 1234),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			name: "successfully validate an OIDCClient for offline access without kube API access with groups",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code", "refresh_token"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid", "offline_access", "groups"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Ready",
					Conditions: []metav1.Condition{
						happyAllowedGrantTypesCondition(now, 1234),
						happyAllowedScopesCondition(now, 1234),
						happyClientSecretsCondition(1, now, 1234),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			name: "successfully validate an OIDCClient for offline access without kube API access with both username and groups",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code", "refresh_token"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid", "offline_access", "username", "groups"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Ready",
					Conditions: []metav1.Condition{
						happyAllowedGrantTypesCondition(now, 1234),
						happyAllowedScopesCondition(now, 1234),
						happyClientSecretsCondition(1, now, 1234),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			name: "successfully validate an OIDCClient without offline access without kube API access with username",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid", "username"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Ready",
					Conditions: []metav1.Condition{
						happyAllowedGrantTypesCondition(now, 1234),
						happyAllowedScopesCondition(now, 1234),
						happyClientSecretsCondition(1, now, 1234),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			name: "successfully validate an OIDCClient without offline access without kube API access with groups",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid", "username"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Ready",
					Conditions: []metav1.Condition{
						happyAllowedGrantTypesCondition(now, 1234),
						happyAllowedScopesCondition(now, 1234),
						happyClientSecretsCondition(1, now, 1234),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
		{
			name: "successfully validate an OIDCClient without offline access without kube API access with both username and groups",
			inputObjects: []runtime.Object{&supervisorconfigv1alpha1.OIDCClient{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Spec: supervisorconfigv1alpha1.OIDCClientSpec{
					AllowedGrantTypes: []supervisorconfigv1alpha1.GrantType{"authorization_code"},
					AllowedScopes:     []supervisorconfigv1alpha1.Scope{"openid", "username", "groups"},
				},
			}},
			inputSecrets:   []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})},
			wantAPIActions: 1, // one update
			wantResultingOIDCClients: []supervisorconfigv1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
				Status: supervisorconfigv1alpha1.OIDCClientStatus{
					Phase: "Ready",
					Conditions: []metav1.Condition{
						happyAllowedGrantTypesCondition(now, 1234),
						happyAllowedScopesCondition(now, 1234),
						happyClientSecretsCondition(1, now, 1234),
					},
					TotalClientSecrets: 1,
				},
			}},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			fakePinnipedClient := supervisorfake.NewSimpleClientset(tt.inputObjects...)
			fakePinnipedClientForInformers := supervisorfake.NewSimpleClientset(tt.inputObjects...)
			pinnipedInformers := supervisorinformers.NewSharedInformerFactory(fakePinnipedClientForInformers, 0)
			fakeKubeClient := kubefake.NewClientset(tt.inputSecrets...)
			kubeInformers := k8sinformers.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 []supervisorconfigv1alpha1.OIDCClient, now metav1.Time) []supervisorconfigv1alpha1.OIDCClient {
	result := make([]supervisorconfigv1alpha1.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 = supervisorconfigv1alpha1.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
}
