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

package clientsecretrequest

import (
	"bytes"
	"context"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"
	"testing"

	"github.com/stretchr/testify/require"
	corev1 "k8s.io/api/core/v1"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/types"
	genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
	"k8s.io/apiserver/pkg/registry/rest"
	kubefake "k8s.io/client-go/kubernetes/fake"
	coretesting "k8s.io/client-go/testing"
	"k8s.io/klog/v2"

	clientsecretapi "go.pinniped.dev/generated/latest/apis/supervisor/clientsecret"
	"go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
	supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake"
	"go.pinniped.dev/internal/oidcclientsecretstorage"
	"go.pinniped.dev/internal/plog"
)

func TestNew(t *testing.T) {
	r := NewREST(
		schema.GroupResource{Group: "bears", Resource: "panda"},
		nil,
		nil,
		"foobar",
		4,
		nil,
		nil,
		nil,
	)

	require.NotNil(t, r)
	require.True(t, r.NamespaceScoped())
	require.Equal(t, []string{"pinniped"}, r.Categories())
	require.Equal(t, "oidcclientsecretrequest", r.GetSingularName())

	require.IsType(t, &clientsecretapi.OIDCClientSecretRequest{}, r.New())
	require.IsType(t, &clientsecretapi.OIDCClientSecretRequestList{}, r.NewList())

	ctx := context.Background()

	// check the simple invariants of our no-op list
	list, err := r.List(ctx, nil)
	require.NoError(t, err)
	require.NotNil(t, list)
	require.IsType(t, &clientsecretapi.OIDCClientSecretRequestList{}, list)
	require.Equal(t, "0", list.(*clientsecretapi.OIDCClientSecretRequestList).ResourceVersion)
	require.NotNil(t, list.(*clientsecretapi.OIDCClientSecretRequestList).Items)
	require.Len(t, list.(*clientsecretapi.OIDCClientSecretRequestList).Items, 0)

	// make sure we can turn lists into tables if needed
	table, err := r.ConvertToTable(ctx, list, nil)
	require.NoError(t, err)
	require.NotNil(t, table)
	require.Equal(t, "0", table.ResourceVersion)
	require.Nil(t, table.Rows)

	// exercise group resource - force error by passing a runtime.Object that does not have an embedded object meta
	_, err = r.ConvertToTable(ctx, &metav1.APIGroup{}, nil)
	require.Error(t, err, "the resource panda.bears does not support being converted to a Table")
}

func TestCreate(t *testing.T) {
	type wantHashes struct {
		UID    string
		hashes []string
	}
	type args struct {
		ctx              context.Context
		obj              runtime.Object
		createValidation rest.ValidateObjectFunc
		options          *metav1.CreateOptions
	}
	namespace := "some-namespace"
	namespacedContext := genericapirequest.WithNamespace(
		genericapirequest.WithRequestInfo(
			genericapirequest.NewContext(),
			&genericapirequest.RequestInfo{
				APIGroup:   "clientsecret.supervisor.pinniped.dev",
				APIVersion: "v1alpha1",
				Resource:   "oidcclientsecretrequests",
			},
		),
		namespace,
	)

	fakeRandomBytes := "0123456789abcdefghijklmnopqrstuv"
	fakeHexEncodedRandomBytes := hex.EncodeToString([]byte(fakeRandomBytes))
	fakeBcryptRandomBytes := fakeHexEncodedRandomBytes + ":4-fake-hash"

	fakeNow := metav1.Now()
	fakeTimeNowFunc := func() metav1.Time { return fakeNow }

	tests := []struct {
		name              string
		args              args
		seedOIDCClients   []*v1alpha1.OIDCClient
		seedHashes        func(storage *oidcclientsecretstorage.OIDCClientSecretStorage)
		addReactors       func(*kubefake.Clientset, *supervisorfake.Clientset)
		fakeByteGenerator io.Reader
		fakeHasher        byteHasher
		want              runtime.Object
		wantErrStatus     *metav1.Status
		wantHashes        *wantHashes
		wantLogLines      []string
	}{
		{
			name: "wrong type of request object provided",
			args: args{
				ctx: namespacedContext,
				obj: &metav1.Status{},
			},
			want: nil,
			wantErrStatus: &metav1.Status{
				Status: metav1.StatusFailure,
				Message: `not an OIDCClientSecretRequest: &v1.Status{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ` +
					`ListMeta:v1.ListMeta{SelfLink:"", ResourceVersion:"", Continue:"", RemainingItemCount:(*int64)(nil)},` +
					` Status:"", Message:"", Reason:"", Details:(*v1.StatusDetails)(nil), Code:0}`,
				Reason: metav1.StatusReasonBadRequest,
				Code:   http.StatusBadRequest,
			},
			wantLogLines: []string{
				`"create"`,
				`failureType:request validation,msg:not an OIDCClientSecretRequest`,
				`END`,
			},
		},
		{
			name: "bad options for dry run",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-some-client-name",
					},
				},
				options: &metav1.CreateOptions{DryRun: []string{"stuff"}},
			},
			want: nil,
			wantErrStatus: &metav1.Status{
				Status: metav1.StatusFailure,
				Message: `OIDCClientSecretRequest.clientsecret.supervisor.pinniped.dev "client.oauth.pinniped.dev-some-client-name" ` +
					`is invalid: dryRun: Unsupported value: []string{"stuff"}`,
				Reason: metav1.StatusReasonInvalid,
				Code:   http.StatusUnprocessableEntity,
				Details: &metav1.StatusDetails{
					Group: "clientsecret.supervisor.pinniped.dev",
					Kind:  "OIDCClientSecretRequest",
					Name:  "client.oauth.pinniped.dev-some-client-name",
					Causes: []metav1.StatusCause{{
						Type:    "FieldValueNotSupported",
						Message: "Unsupported value: []string{\"stuff\"}",
						Field:   "dryRun",
					}},
				},
			},
			wantLogLines: []string{
				`"create"`,
				`failureType:request validation,msg:dryRun not supported`,
				`END`,
			},
		},
		{
			name: "incorrect namespace on request context",
			args: args{
				ctx: genericapirequest.WithNamespace(genericapirequest.NewContext(), "wrong-namespace"),
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-some-client-name",
					},
				},
			},
			want: nil,
			wantErrStatus: &metav1.Status{
				Status:  metav1.StatusFailure,
				Message: `namespace must be some-namespace on OIDCClientSecretRequest, was wrong-namespace`,
				Reason:  metav1.StatusReasonBadRequest,
				Code:    http.StatusBadRequest,
			},
			wantLogLines: []string{
				`"create"`,
				`failureType:request validation,msg:namespace must be some-namespace on OIDCClientSecretRequest, was wrong-namespace`,
				`END`,
			},
		},
		{
			name: "create validation: failure from kube api-server rest.ValidateObjectFunc",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-some-client-name",
					},
				},
				createValidation: func(ctx context.Context, obj runtime.Object) error {
					return apierrors.NewInternalError(errors.New("some-error-here"))
				},
			},
			want: nil,
			wantErrStatus: &metav1.Status{
				Status:  metav1.StatusFailure,
				Message: "Internal error occurred: some-error-here",
				Reason:  metav1.StatusReasonInternalError,
				Code:    http.StatusInternalServerError,
				Details: &metav1.StatusDetails{
					Causes: []metav1.StatusCause{{
						Message: "some-error-here",
					}},
				},
			},
			wantLogLines: []string{
				`"create"`,
				`failureType:validation webhook,msg:Internal error occurred: some-error-here`,
				`END`,
			},
		},
		{
			name: "create validation: no namespace on the request context",
			args: args{
				ctx: genericapirequest.NewContext(),
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-some-client-name",
					},
				},
			},
			want: nil,
			wantErrStatus: &metav1.Status{
				Status:  metav1.StatusFailure,
				Message: "Internal error occurred: no namespace information found in request context",
				Reason:  metav1.StatusReasonInternalError,
				Code:    http.StatusInternalServerError,
				Details: &metav1.StatusDetails{
					Causes: []metav1.StatusCause{{
						Message: "no namespace information found in request context",
					}},
				},
			},
			wantLogLines: []string{
				`"create"`,
				`failureType:request validation,msg:no namespace information found in request context`,
				`END`,
			},
		},
		{
			name: "create validation: namespace on object does not match namespace on request",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name:      "client.oauth.pinniped.dev-some-client-name",
						Namespace: "not-a-matching-namespace",
					},
				},
			},
			want: nil,
			wantErrStatus: &metav1.Status{
				Status:  metav1.StatusFailure,
				Message: "the namespace of the provided object does not match the namespace sent on the request",
				Reason:  metav1.StatusReasonBadRequest,
				Code:    http.StatusBadRequest,
			},
			wantLogLines: []string{
				`"create"`,
				`failureType:request validation,msg:the namespace of the provided object does not match the namespace sent on the request`,
				`END`,
			},
		},
		{
			name: "create validation: generateName is unsupported",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						GenerateName: "foo",
					},
				},
			},
			want: nil,
			wantErrStatus: &metav1.Status{
				Status: metav1.StatusFailure,
				Message: `OIDCClientSecretRequest.clientsecret.supervisor.pinniped.dev "" is invalid: [metadata.generateName: ` +
					`Invalid value: "foo": generateName is not supported, metadata.name: Required value: name or generateName is required]`,
				Reason: metav1.StatusReasonInvalid,
				Code:   http.StatusUnprocessableEntity,
				Details: &metav1.StatusDetails{
					Group: "clientsecret.supervisor.pinniped.dev",
					Kind:  "OIDCClientSecretRequest",
					Name:  "",
					Causes: []metav1.StatusCause{{
						Type:    metav1.CauseTypeFieldValueInvalid,
						Message: `Invalid value: "foo": generateName is not supported`,
						Field:   "metadata.generateName",
					}, {
						Type:    metav1.CauseTypeFieldValueRequired,
						Message: "Required value: name or generateName is required",
						Field:   "metadata.name",
					}},
				},
			},
			wantLogLines: []string{
				`"create"`,
				`failureType:request validation,msg:[metadata.generateName: Invalid value: "foo": generateName is not supported, metadata.name: Required value: name or generateName is required]`,
				`END`,
			},
		},
		{
			name: "create validation: name cannot exactly match client.oauth.pinniped.dev-",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-",
					},
				},
			},
			want: nil,
			wantErrStatus: &metav1.Status{
				Status: metav1.StatusFailure,
				Message: `OIDCClientSecretRequest.clientsecret.supervisor.pinniped.dev "client.oauth.pinniped.dev-" is invalid: ` +
					`metadata.name: Invalid value: "client.oauth.pinniped.dev-": must not equal 'client.oauth.pinniped.dev-'`,
				Reason: metav1.StatusReasonInvalid,
				Code:   http.StatusUnprocessableEntity,
				Details: &metav1.StatusDetails{
					Group: "clientsecret.supervisor.pinniped.dev",
					Kind:  "OIDCClientSecretRequest",
					Name:  "client.oauth.pinniped.dev-",
					Causes: []metav1.StatusCause{{
						Type:    metav1.CauseTypeFieldValueInvalid,
						Message: `Invalid value: "client.oauth.pinniped.dev-": must not equal 'client.oauth.pinniped.dev-'`,
						Field:   "metadata.name",
					}},
				},
			},
			wantLogLines: []string{
				`"create"`,
				`failureType:request validation,msg:metadata.name: Invalid value: "client.oauth.pinniped.dev-": must not equal 'client.oauth.pinniped.dev-'`,
				`END`,
			},
		},
		{
			name: "create validation: name must contain prefix client.oauth.pinniped.dev-",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "does-not-contain-the-prefix",
					},
				},
			},
			want: nil,
			wantErrStatus: &metav1.Status{
				Status: metav1.StatusFailure,
				Message: `OIDCClientSecretRequest.clientsecret.supervisor.pinniped.dev "does-not-contain-the-prefix" is invalid: ` +
					`metadata.name: Invalid value: "does-not-contain-the-prefix": must start with 'client.oauth.pinniped.dev-'`,
				Reason: metav1.StatusReasonInvalid,
				Code:   http.StatusUnprocessableEntity,
				Details: &metav1.StatusDetails{
					Group: "clientsecret.supervisor.pinniped.dev",
					Kind:  "OIDCClientSecretRequest",
					Name:  "does-not-contain-the-prefix",
					Causes: []metav1.StatusCause{{
						Type:    metav1.CauseTypeFieldValueInvalid,
						Message: `Invalid value: "does-not-contain-the-prefix": must start with 'client.oauth.pinniped.dev-'`,
						Field:   "metadata.name",
					}},
				},
			},
			wantLogLines: []string{
				`"create"`,
				`failureType:request validation,msg:metadata.name: Invalid value: "does-not-contain-the-prefix": must start with 'client.oauth.pinniped.dev-'`,
				`END`,
			},
		},
		{
			name: "create validation: name with invalid characters should error",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-contains/invalid/characters",
					},
				},
			},
			want: nil,
			wantErrStatus: &metav1.Status{
				Status: metav1.StatusFailure,
				Message: `OIDCClientSecretRequest.clientsecret.supervisor.pinniped.dev "client.oauth.pinniped.dev-contains/invalid/characters" ` +
					`is invalid: metadata.name: Invalid value: "client.oauth.pinniped.dev-contains/invalid/characters": may not contain '/'`,
				Reason: metav1.StatusReasonInvalid,
				Code:   http.StatusUnprocessableEntity,
				Details: &metav1.StatusDetails{
					Group: "clientsecret.supervisor.pinniped.dev",
					Kind:  "OIDCClientSecretRequest",
					Name:  "client.oauth.pinniped.dev-contains/invalid/characters",
					Causes: []metav1.StatusCause{{
						Type:    metav1.CauseTypeFieldValueInvalid,
						Message: `Invalid value: "client.oauth.pinniped.dev-contains/invalid/characters": may not contain '/'`,
						Field:   "metadata.name",
					}},
				},
			},
			wantLogLines: []string{
				`"create"`,
				`failureType:request validation,msg:metadata.name: Invalid value: "client.oauth.pinniped.dev-contains/invalid/characters": may not contain '/'`,
				`END`,
			},
		},
		{
			name: "create validation: name validation may return multiple errors",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name:         "multiple/errors/aggregated",
						GenerateName: "no-generate-allowed",
					},
				},
			},
			want: nil,
			wantErrStatus: &metav1.Status{
				Status: metav1.StatusFailure,
				Message: `OIDCClientSecretRequest.clientsecret.supervisor.pinniped.dev "multiple/errors/aggregated" is invalid: [metadata.generateName: ` +
					`Invalid value: "no-generate-allowed": generateName is not supported, metadata.name: ` +
					`Invalid value: "multiple/errors/aggregated": must start with 'client.oauth.pinniped.dev-', metadata.name: ` +
					`Invalid value: "multiple/errors/aggregated": may not contain '/']`,
				Reason: metav1.StatusReasonInvalid,
				Code:   http.StatusUnprocessableEntity,
				Details: &metav1.StatusDetails{
					Group: "clientsecret.supervisor.pinniped.dev",
					Kind:  "OIDCClientSecretRequest",
					Name:  "multiple/errors/aggregated",
					Causes: []metav1.StatusCause{{
						Type:    metav1.CauseTypeFieldValueInvalid,
						Message: `Invalid value: "no-generate-allowed": generateName is not supported`,
						Field:   "metadata.generateName",
					}, {
						Type:    metav1.CauseTypeFieldValueInvalid,
						Message: `Invalid value: "multiple/errors/aggregated": must start with 'client.oauth.pinniped.dev-'`,
						Field:   "metadata.name",
					}, {
						Type:    metav1.CauseTypeFieldValueInvalid,
						Message: `Invalid value: "multiple/errors/aggregated": may not contain '/'`,
						Field:   "metadata.name",
					}},
				},
			},
			wantLogLines: []string{
				`"create"`,
				`failureType:request validation,msg:[metadata.generateName: Invalid value: "no-generate-allowed": generateName is not supported, metadata.name: Invalid value: "multiple/errors/aggregated": must start with 'client.oauth.pinniped.dev-', metadata.name: Invalid value: "multiple/errors/aggregated": may not contain '/']`,
				`END`,
			},
		},
		{
			name: "oidcClient does not exist 404",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-oidc-client-does-not-exist-404",
					},
				},
			},
			want: nil,
			wantErrStatus: &metav1.Status{
				Status: metav1.StatusFailure,
				Message: `OIDCClientSecretRequest.clientsecret.supervisor.pinniped.dev "client.oauth.pinniped.dev-oidc-client-does-not-exist-404" ` +
					`is invalid: metadata.name: Not found: "client.oauth.pinniped.dev-oidc-client-does-not-exist-404"`,
				Reason: metav1.StatusReasonInvalid,
				Code:   http.StatusUnprocessableEntity,
				Details: &metav1.StatusDetails{
					Group: "clientsecret.supervisor.pinniped.dev",
					Kind:  "OIDCClientSecretRequest",
					Name:  "client.oauth.pinniped.dev-oidc-client-does-not-exist-404",
					Causes: []metav1.StatusCause{{
						Type:    metav1.CauseTypeFieldValueNotFound,
						Message: `Not found: "client.oauth.pinniped.dev-oidc-client-does-not-exist-404"`,
						Field:   "metadata.name",
					}},
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`failureType:oidcClientsClient.Get,msg:oidcclients.config.supervisor.pinniped.dev "client.oauth.pinniped.dev-oidc-client-does-not-exist-404" not found`,
				`END`,
			},
		},
		{
			name: "unexpected error getting oidcClient 500",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-internal-error-could-not-get-client",
					},
				},
			},
			addReactors: func(kubeClient *kubefake.Clientset, supervisorClient *supervisorfake.Clientset) {
				supervisorClient.PrependReactor("get", "oidcclients", func(action coretesting.Action) (bool, runtime.Object, error) {
					return true, nil, errors.New("unexpected error darn")
				})
			},
			wantErrStatus: &metav1.Status{
				Status:  metav1.StatusFailure,
				Code:    http.StatusInternalServerError,
				Reason:  metav1.StatusReasonInternalError,
				Message: `Internal error occurred: getting client "client.oauth.pinniped.dev-internal-error-could-not-get-client" failed`,
				Details: &metav1.StatusDetails{
					Causes: []metav1.StatusCause{{
						Message: `getting client "client.oauth.pinniped.dev-internal-error-could-not-get-client" failed`,
					}},
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`failureType:oidcClientsClient.Get,msg:unexpected error darn`,
				`END`,
			},
		},
		{
			name: "failed to get kube secret for oidcClient",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-no-secret-for-oidcclient",
					},
				},
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-no-secret-for-oidcclient",
					Namespace: namespace,
				},
			}},
			addReactors: func(kubeClient *kubefake.Clientset, supervisorClient *supervisorfake.Clientset) {
				kubeClient.PrependReactor("get", "secrets", func(action coretesting.Action) (bool, runtime.Object, error) {
					return true, nil, errors.New("sadly no secrets")
				})
			},
			wantErrStatus: &metav1.Status{
				Status:  metav1.StatusFailure,
				Code:    http.StatusInternalServerError,
				Reason:  metav1.StatusReasonInternalError,
				Message: `Internal error occurred: getting secret for client "client.oauth.pinniped.dev-no-secret-for-oidcclient" failed`,
				Details: &metav1.StatusDetails{
					Causes: []metav1.StatusCause{{
						Message: `getting secret for client "client.oauth.pinniped.dev-no-secret-for-oidcclient" failed`,
					}},
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`failureType:secretStorage.Get,msg:failed to get client secret for uid : failed to get oidc-client-secret for signature : sadly no secrets`,
				`END`,
			},
		},
		{
			name: "failed to generate new client secret for oidcClient",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-fail-to-generate-secret",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: true,
						RevokeOldSecrets:  false,
					},
				},
			},
			fakeByteGenerator: readerAlwaysErrors{},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-fail-to-generate-secret",
					Namespace: namespace,
				},
			}},
			wantErrStatus: &metav1.Status{
				Status:  metav1.StatusFailure,
				Code:    http.StatusInternalServerError,
				Reason:  metav1.StatusReasonInternalError,
				Message: `Internal error occurred: client secret generation failed`,
				Details: &metav1.StatusDetails{
					Causes: []metav1.StatusCause{{
						Message: `client secret generation failed`,
					}},
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`failureType:generateSecret,msg:could not generate client secret: always errors`,
				`END`,
			},
		},
		{
			name: "failed to generate hash for new client secret for oidcClient",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-fail-to-hash-secret",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: true,
						RevokeOldSecrets:  false,
					},
				},
			},
			fakeHasher: func(password []byte, cost int) ([]byte, error) {
				return nil, errors.New("can't hash stuff")
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-fail-to-hash-secret",
					Namespace: namespace,
				},
			}},
			wantErrStatus: &metav1.Status{
				Status:  metav1.StatusFailure,
				Code:    http.StatusInternalServerError,
				Reason:  metav1.StatusReasonInternalError,
				Message: `Internal error occurred: hash generation failed`,
				Details: &metav1.StatusDetails{
					Causes: []metav1.StatusCause{{
						Message: `hash generation failed`,
					}},
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`generateSecret`,
				`failureType:bcrypt.GenerateFromPassword,msg:can't hash stuff`,
				`END`,
			},
		},
		{
			name: "happy path: no secrets exist, create secret and hash for found oidcclient",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-happy-new-secret",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: true,
						RevokeOldSecrets:  false,
					},
				},
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-happy-new-secret",
					Namespace: namespace,
					UID:       "12345",
				},
			}},
			want: &clientsecretapi.OIDCClientSecretRequest{
				ObjectMeta: metav1.ObjectMeta{
					Name:              "client.oauth.pinniped.dev-happy-new-secret",
					Namespace:         namespace,
					CreationTimestamp: fakeNow,
				},
				Spec: clientsecretapi.OIDCClientSecretRequestSpec{
					GenerateNewSecret: true,
					RevokeOldSecrets:  false,
				},
				Status: clientsecretapi.OIDCClientSecretRequestStatus{
					GeneratedSecret:    fakeHexEncodedRandomBytes,
					TotalClientSecrets: 1,
				},
			},
			wantErrStatus: nil,
			wantHashes: &wantHashes{
				UID: "12345",
				hashes: []string{
					fakeBcryptRandomBytes,
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`generateSecret`,
				`bcrypt.GenerateFromPassword`,
				`secretStorage.Set`,
				`END`,
			},
		},
		{
			name: "happy path: secret exists, prepend new secret hash to secret to the list of hashes for found oidcclient",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-append-new-secret-hash",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: true,
						RevokeOldSecrets:  false,
					},
				},
			},
			seedHashes: func(storage *oidcclientsecretstorage.OIDCClientSecretStorage) {
				require.NoError(t,
					storage.Set(
						context.Background(),
						"",
						"client.oauth.pinniped.dev-append-new-secret-hash",
						"12345",
						[]string{
							"hashed-password-1",
							"hashed-password-2",
						},
					),
				)
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-append-new-secret-hash",
					Namespace: namespace,
					UID:       "12345",
				},
			}},
			wantHashes: &wantHashes{
				UID: "12345",
				hashes: []string{
					fakeBcryptRandomBytes,
					"hashed-password-1",
					"hashed-password-2",
				},
			},
			want: &clientsecretapi.OIDCClientSecretRequest{
				ObjectMeta: metav1.ObjectMeta{
					Name:              "client.oauth.pinniped.dev-append-new-secret-hash",
					Namespace:         namespace,
					CreationTimestamp: fakeNow,
				},
				Spec: clientsecretapi.OIDCClientSecretRequestSpec{
					GenerateNewSecret: true,
					RevokeOldSecrets:  false,
				},
				Status: clientsecretapi.OIDCClientSecretRequestStatus{
					GeneratedSecret:    fakeHexEncodedRandomBytes,
					TotalClientSecrets: 3,
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`generateSecret`,
				`bcrypt.GenerateFromPassword`,
				`secretStorage.Set`,
				`END`,
			},
		},
		{
			name: "happy path: secret exists, append new secret hash to secret and revoke old for found oidcclient",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-append-new-secret-hash",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: true,
						RevokeOldSecrets:  true,
					},
				},
			},
			seedHashes: func(storage *oidcclientsecretstorage.OIDCClientSecretStorage) {
				require.NoError(t,
					storage.Set(
						context.Background(),
						"",
						"client.oauth.pinniped.dev-append-new-secret-hash",
						"12345",
						[]string{
							"hashed-password-1",
							"hashed-password-2",
						},
					))
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-append-new-secret-hash",
					Namespace: namespace,
					UID:       "12345",
				},
			}},
			wantHashes: &wantHashes{
				UID: "12345",
				hashes: []string{
					fakeBcryptRandomBytes,
				},
			},
			want: &clientsecretapi.OIDCClientSecretRequest{
				ObjectMeta: metav1.ObjectMeta{
					Name:              "client.oauth.pinniped.dev-append-new-secret-hash",
					Namespace:         namespace,
					CreationTimestamp: fakeNow,
				},
				Spec: clientsecretapi.OIDCClientSecretRequestSpec{
					GenerateNewSecret: true,
					RevokeOldSecrets:  true,
				},
				Status: clientsecretapi.OIDCClientSecretRequestStatus{
					GeneratedSecret:    fakeHexEncodedRandomBytes,
					TotalClientSecrets: 1,
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`generateSecret`,
				`bcrypt.GenerateFromPassword`,
				`secretStorage.Set`,
				`END`,
			},
		},
		{
			name: "happy path: secret exists, revoke old secrets but retain latest for found oidcclient",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-some-client",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: false,
						RevokeOldSecrets:  true,
					},
				},
			},
			seedHashes: func(storage *oidcclientsecretstorage.OIDCClientSecretStorage) {
				require.NoError(t,
					storage.Set(
						context.Background(),
						"",
						"client.oauth.pinniped.dev-some-client",
						"12345",
						[]string{
							"hashed-password-1",
							"hashed-password-2",
						},
					))
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-some-client",
					Namespace: namespace,
					UID:       "12345",
				},
			}},
			wantHashes: &wantHashes{
				UID: "12345",
				hashes: []string{
					"hashed-password-1",
				},
			},
			want: &clientsecretapi.OIDCClientSecretRequest{
				ObjectMeta: metav1.ObjectMeta{
					Name:              "client.oauth.pinniped.dev-some-client",
					Namespace:         namespace,
					CreationTimestamp: fakeNow,
				},
				Spec: clientsecretapi.OIDCClientSecretRequestSpec{
					GenerateNewSecret: false,
					RevokeOldSecrets:  true,
				},
				Status: clientsecretapi.OIDCClientSecretRequestStatus{
					GeneratedSecret:    "",
					TotalClientSecrets: 1,
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`secretStorage.Set`,
				`END`,
			},
		},
		{
			name: "secret exists but oidcclient secret has too many hashes, fails to create when RevokeOldSecrets:false (max 5), secret is not updated",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-some-client",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: true,
						RevokeOldSecrets:  false,
					},
				},
			},
			seedHashes: func(storage *oidcclientsecretstorage.OIDCClientSecretStorage) {
				require.NoError(t,
					storage.Set(
						context.Background(),
						"",
						"client.oauth.pinniped.dev-some-client",
						"12345",
						[]string{
							"hashed-password-1",
							"hashed-password-2",
							"hashed-password-3",
							"hashed-password-4",
							"hashed-password-5",
						},
					))
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-some-client",
					Namespace: namespace,
					UID:       "12345",
				},
			}},
			wantHashes: &wantHashes{
				UID: "12345",
				hashes: []string{
					"hashed-password-1",
					"hashed-password-2",
					"hashed-password-3",
					"hashed-password-4",
					"hashed-password-5",
				},
			},
			wantErrStatus: &metav1.Status{
				Status:  metav1.StatusFailure,
				Message: `OIDCClient client.oauth.pinniped.dev-some-client has too many secrets, spec.revokeOldSecrets must be true`,
				Reason:  metav1.StatusReasonBadRequest,
				Code:    http.StatusBadRequest,
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`generateSecret`,
				`bcrypt.GenerateFromPassword`,
				`failureType:secretStorage.Set,msg:OIDCClient client.oauth.pinniped.dev-some-client has too many secrets, spec.revokeOldSecrets must be true`,
				`END`,
			},
			want: nil,
		},
		{
			name: "secret exists but oidcclient secret has too many hashes, fails to create when RevokeOldSecrets:false (greater than 5), secret is not updated",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-some-client",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: true,
						RevokeOldSecrets:  false,
					},
				},
			},
			seedHashes: func(storage *oidcclientsecretstorage.OIDCClientSecretStorage) {
				require.NoError(t,
					storage.Set(
						context.Background(),
						"",
						"client.oauth.pinniped.dev-some-client",
						"12345",
						[]string{
							"hashed-password-1",
							"hashed-password-2",
							"hashed-password-3",
							"hashed-password-4",
							"hashed-password-5",
							"hashed-password-6",
						},
					))
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-some-client",
					Namespace: namespace,
					UID:       "12345",
				},
			}},
			wantHashes: &wantHashes{
				UID: "12345",
				hashes: []string{
					"hashed-password-1",
					"hashed-password-2",
					"hashed-password-3",
					"hashed-password-4",
					"hashed-password-5",
					"hashed-password-6",
				},
			},
			wantErrStatus: &metav1.Status{
				Status:  metav1.StatusFailure,
				Message: `OIDCClient client.oauth.pinniped.dev-some-client has too many secrets, spec.revokeOldSecrets must be true`,
				Reason:  metav1.StatusReasonBadRequest,
				Code:    http.StatusBadRequest,
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`generateSecret`,
				`bcrypt.GenerateFromPassword`,
				`failureType:secretStorage.Set,msg:OIDCClient client.oauth.pinniped.dev-some-client has too many secrets, spec.revokeOldSecrets must be true`,
				`END`,
			},
		},
		{
			name: "attempted to create storage secret because it did not initially exist but was created by someone else while generating new client secret & hash",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-some-client",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: true,
						RevokeOldSecrets:  false,
					},
				},
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-some-client",
					Namespace: namespace,
					UID:       "12345",
				},
			}},
			addReactors: func(kubeClient *kubefake.Clientset, supervisorClient *supervisorfake.Clientset) {
				kubeClient.PrependReactor("create", "secrets", func(action coretesting.Action) (bool, runtime.Object, error) {
					secret := action.(coretesting.UpdateAction).GetObject().(*corev1.Secret)
					return true, nil, apierrors.NewAlreadyExists(schema.GroupResource{Group: "", Resource: "secrets"}, secret.Name)
				})
			},
			wantErrStatus: &metav1.Status{
				Status: metav1.StatusFailure,
				Message: `Operation cannot be fulfilled on oidcclientsecretrequests.clientsecret.supervisor.pinniped.dev ` +
					`"client.oauth.pinniped.dev-some-client": multiple concurrent secret generation requests for same client`,
				Reason: metav1.StatusReasonConflict,
				Code:   http.StatusConflict,
				Details: &metav1.StatusDetails{
					Group: "clientsecret.supervisor.pinniped.dev",
					Kind:  "oidcclientsecretrequests",
					Name:  "client.oauth.pinniped.dev-some-client",
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`generateSecret`,
				`bcrypt.GenerateFromPassword`,
				`failureType:secretStorage.Set,msg:failed to create client secret for uid 12345: failed to create oidc-client-secret for signature MTIzNDU: secrets "pinniped-storage-oidc-client-secret-gezdgnbv" already exists`,
				`END`,
			},
		},
		{
			name: "attempted to create storage secret because it did not initially exist but received a conflict error",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-some-client",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: true,
						RevokeOldSecrets:  false,
					},
				},
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-some-client",
					Namespace: namespace,
					UID:       "12345",
				},
			}},
			addReactors: func(kubeClient *kubefake.Clientset, supervisorClient *supervisorfake.Clientset) {
				kubeClient.PrependReactor("create", "secrets", func(action coretesting.Action) (bool, runtime.Object, error) {
					secret := action.(coretesting.UpdateAction).GetObject().(*corev1.Secret)
					return true, nil, apierrors.NewConflict(
						schema.GroupResource{Group: "", Resource: "secrets"},
						secret.Name,
						errors.New("something deeply conflicted"),
					)
				})
			},
			wantErrStatus: &metav1.Status{
				Status: metav1.StatusFailure,
				Message: `Operation cannot be fulfilled on oidcclientsecretrequests.clientsecret.supervisor.pinniped.dev ` +
					`"client.oauth.pinniped.dev-some-client": multiple concurrent secret generation requests for same client`,
				Reason: metav1.StatusReasonConflict,
				Code:   http.StatusConflict,
				Details: &metav1.StatusDetails{
					Group: "clientsecret.supervisor.pinniped.dev",
					Kind:  "oidcclientsecretrequests",
					Name:  "client.oauth.pinniped.dev-some-client",
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`generateSecret`,
				`bcrypt.GenerateFromPassword`,
				`failureType:secretStorage.Set,msg:failed to create client secret for uid 12345: failed to create oidc-client-secret for signature MTIzNDU: Operation cannot be fulfilled on secrets "pinniped-storage-oidc-client-secret-gezdgnbv": something deeply conflicted`,
				`END`,
			},
		},
		{
			name: "attempted to create storage secret but received an unknown error",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-some-client",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: true,
						RevokeOldSecrets:  false,
					},
				},
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-some-client",
					Namespace: namespace,
					UID:       "12345",
				},
			}},
			addReactors: func(kubeClient *kubefake.Clientset, supervisorClient *supervisorfake.Clientset) {
				kubeClient.PrependReactor("create", "secrets", func(action coretesting.Action) (bool, runtime.Object, error) {
					return true, nil, errors.New("some random error")
				})
			},
			wantErrStatus: &metav1.Status{
				Status:  metav1.StatusFailure,
				Message: `Internal error occurred: setting client secret failed`,
				Reason:  metav1.StatusReasonInternalError,
				Code:    http.StatusInternalServerError,
				Details: &metav1.StatusDetails{
					Causes: []metav1.StatusCause{{
						Message: "setting client secret failed",
					}},
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`generateSecret`,
				`bcrypt.GenerateFromPassword`,
				`failureType:secretStorage.Set,msg:failed to create client secret for uid 12345: failed to create oidc-client-secret for signature MTIzNDU: some random error`,
				`END`,
			},
		},
		{
			name: "happy path noop: do not create a new secret, do not revoke old secrets, but there is no existing storage secret",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-happy-new-secret",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: false,
						RevokeOldSecrets:  true,
					},
				},
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-happy-new-secret",
					Namespace: namespace,
					UID:       "12345",
				},
			}},
			want: &clientsecretapi.OIDCClientSecretRequest{
				ObjectMeta: metav1.ObjectMeta{
					Name:              "client.oauth.pinniped.dev-happy-new-secret",
					Namespace:         namespace,
					CreationTimestamp: fakeNow,
				},
				Spec: clientsecretapi.OIDCClientSecretRequestSpec{
					GenerateNewSecret: false,
					RevokeOldSecrets:  true,
				},
				Status: clientsecretapi.OIDCClientSecretRequestStatus{
					GeneratedSecret:    "",
					TotalClientSecrets: 0,
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`END`,
			},
		},
		{
			name: "happy path noop: do not create a new secret, revoke old secrets, but there is no existing storage secret",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-some-client",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: false,
						RevokeOldSecrets:  false,
					},
				},
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-some-client",
					Namespace: namespace,
					UID:       "12345",
				},
			}},
			want: &clientsecretapi.OIDCClientSecretRequest{
				ObjectMeta: metav1.ObjectMeta{
					Name:              "client.oauth.pinniped.dev-some-client",
					Namespace:         namespace,
					CreationTimestamp: fakeNow,
				},
				Spec: clientsecretapi.OIDCClientSecretRequestSpec{
					GenerateNewSecret: false,
					RevokeOldSecrets:  false,
				},
				Status: clientsecretapi.OIDCClientSecretRequestStatus{
					GeneratedSecret:    "",
					TotalClientSecrets: 0,
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`END`,
			},
		},
		{
			name: "happy path noop: do not create a new secret, revoke old secrets, and there is an existing storage secret",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-some-client",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: false,
						RevokeOldSecrets:  false,
					},
				},
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-some-client",
					Namespace: namespace,
					UID:       "12345",
				},
			}},
			seedHashes: func(storage *oidcclientsecretstorage.OIDCClientSecretStorage) {
				require.NoError(t,
					storage.Set(
						context.Background(),
						"",
						"client.oauth.pinniped.dev-some-client",
						"12345",
						[]string{
							"hashed-password-1",
							"hashed-password-2",
						},
					))
			},
			wantHashes: &wantHashes{
				UID: "12345",
				hashes: []string{
					"hashed-password-1",
					"hashed-password-2",
				},
			},
			want: &clientsecretapi.OIDCClientSecretRequest{
				ObjectMeta: metav1.ObjectMeta{
					Name:              "client.oauth.pinniped.dev-some-client",
					Namespace:         namespace,
					CreationTimestamp: fakeNow,
				},
				Spec: clientsecretapi.OIDCClientSecretRequestSpec{
					GenerateNewSecret: false,
					RevokeOldSecrets:  false,
				},
				Status: clientsecretapi.OIDCClientSecretRequestStatus{
					GeneratedSecret:    "",
					TotalClientSecrets: 2,
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`END`,
			},
		},
		{
			name: "happy path: generate new secret and revoking old secret when there was a single secret hash to start with",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-some-client",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: true,
						RevokeOldSecrets:  true,
					},
				},
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-some-client",
					Namespace: namespace,
					UID:       "12345",
				},
			}},
			seedHashes: func(storage *oidcclientsecretstorage.OIDCClientSecretStorage) {
				require.NoError(t,
					storage.Set(
						context.Background(),
						"",
						"client.oauth.pinniped.dev-some-client",
						"12345",
						[]string{
							"hashed-password-1",
						},
					))
			},
			wantHashes: &wantHashes{
				UID: "12345",
				hashes: []string{
					fakeBcryptRandomBytes,
				},
			},
			want: &clientsecretapi.OIDCClientSecretRequest{
				ObjectMeta: metav1.ObjectMeta{
					Name:              "client.oauth.pinniped.dev-some-client",
					Namespace:         namespace,
					CreationTimestamp: fakeNow,
				},
				Spec: clientsecretapi.OIDCClientSecretRequestSpec{
					GenerateNewSecret: true,
					RevokeOldSecrets:  true,
				},
				Status: clientsecretapi.OIDCClientSecretRequestStatus{
					GeneratedSecret:    fakeHexEncodedRandomBytes,
					TotalClientSecrets: 1,
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`generateSecret`,
				`bcrypt.GenerateFromPassword`,
				`secretStorage.Set`,
				`END`,
			},
		},
		{
			name: "happy path: generate new secret when existing secrets is max (5)",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-some-client",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: true,
						RevokeOldSecrets:  true,
					},
				},
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-some-client",
					Namespace: namespace,
					UID:       "12345",
				},
			}},
			seedHashes: func(storage *oidcclientsecretstorage.OIDCClientSecretStorage) {
				require.NoError(t,
					storage.Set(
						context.Background(),
						"",
						"client.oauth.pinniped.dev-some-client",
						"12345",
						[]string{
							"hashed-password-1",
							"hashed-password-2",
							"hashed-password-3",
							"hashed-password-4",
							"hashed-password-5",
						},
					))
			},
			wantHashes: &wantHashes{
				UID: "12345",
				hashes: []string{
					fakeBcryptRandomBytes,
				},
			},
			want: &clientsecretapi.OIDCClientSecretRequest{
				ObjectMeta: metav1.ObjectMeta{
					Name:              "client.oauth.pinniped.dev-some-client",
					Namespace:         namespace,
					CreationTimestamp: fakeNow,
				},
				Spec: clientsecretapi.OIDCClientSecretRequestSpec{
					GenerateNewSecret: true,
					RevokeOldSecrets:  true,
				},
				Status: clientsecretapi.OIDCClientSecretRequestStatus{
					GeneratedSecret:    fakeHexEncodedRandomBytes,
					TotalClientSecrets: 1,
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`generateSecret`,
				`bcrypt.GenerateFromPassword`,
				`secretStorage.Set`,
				`END`,
			},
		},
		{
			name: "happy path: generate new secret when existing secrets exceeds maximum (5)",
			args: args{
				ctx: namespacedContext,
				obj: &clientsecretapi.OIDCClientSecretRequest{
					ObjectMeta: metav1.ObjectMeta{
						Name: "client.oauth.pinniped.dev-some-client",
					},
					Spec: clientsecretapi.OIDCClientSecretRequestSpec{
						GenerateNewSecret: true,
						RevokeOldSecrets:  true,
					},
				},
			},
			seedOIDCClients: []*v1alpha1.OIDCClient{{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "client.oauth.pinniped.dev-some-client",
					Namespace: namespace,
					UID:       "12345",
				},
			}},
			seedHashes: func(storage *oidcclientsecretstorage.OIDCClientSecretStorage) {
				require.NoError(t,
					storage.Set(
						context.Background(),
						"",
						"client.oauth.pinniped.dev-some-client",
						"12345",
						[]string{
							"hashed-password-1",
							"hashed-password-2",
							"hashed-password-3",
							"hashed-password-4",
							"hashed-password-5",
							"hashed-password-6",
						},
					))
			},
			wantHashes: &wantHashes{
				UID: "12345",
				hashes: []string{
					fakeBcryptRandomBytes,
				},
			},
			want: &clientsecretapi.OIDCClientSecretRequest{
				ObjectMeta: metav1.ObjectMeta{
					Name:              "client.oauth.pinniped.dev-some-client",
					Namespace:         namespace,
					CreationTimestamp: fakeNow,
				},
				Spec: clientsecretapi.OIDCClientSecretRequestSpec{
					GenerateNewSecret: true,
					RevokeOldSecrets:  true,
				},
				Status: clientsecretapi.OIDCClientSecretRequestStatus{
					GeneratedSecret:    fakeHexEncodedRandomBytes,
					TotalClientSecrets: 1,
				},
			},
			wantLogLines: []string{
				`"create"`,
				`"validateRequest"`,
				`oidcClientsClient.Get`,
				`secretStorage.Get`,
				`generateSecret`,
				`bcrypt.GenerateFromPassword`,
				`secretStorage.Set`,
				`END`,
			},
		},
	}
	for _, tt := range tests {
		tt := tt

		t.Run(tt.name, func(t *testing.T) {
			// t.Parallel() should not be used because we are mutating the global logger.
			var log bytes.Buffer
			logger := plog.TestZapr(t, &log)
			klog.SetLogger(logger)
			t.Cleanup(func() {
				klog.ClearLogger()
			})

			kubeClient := kubefake.NewSimpleClientset()
			secretsClient := kubeClient.CoreV1().Secrets(namespace)
			// Production code depends on secrets having a resource version.
			// Our seedHashes mechanism with the fake client unfortunately does not cause a resourceVersion to be set on the secret.
			// Therefore, we need to add this reactor before we seed hashes so our secrets have RVs.
			kubeClient.PrependReactor("create", "secrets", func(action coretesting.Action) (bool, runtime.Object, error) {
				secret := action.(coretesting.UpdateAction).GetObject().(*corev1.Secret)
				secret.ResourceVersion = "1"
				return false, nil, nil
			})

			oidcClientSecretStore := oidcclientsecretstorage.New(secretsClient)
			if tt.seedHashes != nil {
				tt.seedHashes(oidcClientSecretStore)
			}

			supervisorClient := supervisorfake.NewSimpleClientset()
			if tt.seedOIDCClients != nil {
				for _, client := range tt.seedOIDCClients {
					require.NoError(t, supervisorClient.Tracker().Add(client))
				}
			}
			oidcClientClient := supervisorClient.ConfigV1alpha1().OIDCClients(namespace)

			if tt.addReactors != nil {
				tt.addReactors(kubeClient, supervisorClient)
			}

			fakeHasher := tt.fakeHasher
			if tt.fakeHasher == nil {
				fakeHasher = func(password []byte, cost int) ([]byte, error) {
					return []byte(fmt.Sprintf("%s:%d-fake-hash", password, cost)), nil
				}
			}
			fakeByteGenerator := tt.fakeByteGenerator
			if tt.fakeByteGenerator == nil {
				fakeByteGenerator = strings.NewReader(fakeRandomBytes + "these extra bytes should be ignored since we only read 32 bytes")
			}

			r := NewREST(
				schema.GroupResource{Group: "bears", Resource: "panda"},
				secretsClient,
				oidcClientClient,
				namespace,
				4,
				fakeByteGenerator,
				fakeHasher,
				fakeTimeNowFunc,
			)

			got, err := r.Create(tt.args.ctx, tt.args.obj, tt.args.createValidation, tt.args.options)

			require.Equal(t, tt.want, got)
			if tt.wantErrStatus != nil {
				require.Equal(t, &apierrors.StatusError{ErrStatus: *tt.wantErrStatus}, err)
			} else {
				require.NoError(t, err)
			}

			if tt.wantHashes != nil {
				secretStoreName := oidcClientSecretStore.GetName(types.UID(tt.wantHashes.UID))
				secretGVR := schema.GroupVersionResource{
					Group:    corev1.SchemeGroupVersion.Group,
					Version:  corev1.SchemeGroupVersion.Version,
					Resource: "secrets",
				}
				storeSecret, err := kubeClient.Tracker().Get(secretGVR, namespace, secretStoreName)
				require.NoError(t, err)
				require.IsType(t, &corev1.Secret{}, storeSecret)
				secretHashes, err := oidcclientsecretstorage.ReadFromSecret(storeSecret.(*corev1.Secret))
				require.NoError(t, err)
				require.Equal(t, tt.wantHashes.hashes, secretHashes)
			} else {
				secrets, err := secretsClient.List(context.Background(), metav1.ListOptions{})
				require.NoError(t, err)
				require.Empty(t, secrets.Items)
			}

			requireLogLinesContain(t, log.String(), tt.wantLogLines)
		})
	}
}

type readerAlwaysErrors struct{}

func (r readerAlwaysErrors) Read(_ []byte) (n int, err error) {
	return 0, errors.New("always errors")
}

func requireLogLinesContain(t *testing.T, fullLog string, wantLines []string) {
	if len(wantLines) == 0 {
		require.Empty(t, fullLog)
		return
	}
	var jsonLog map[string]interface{}
	err := json.Unmarshal([]byte(fullLog), &jsonLog)
	require.NoError(t, err)
	require.Contains(t, jsonLog, "message")
	message := jsonLog["message"]
	require.IsType(t, "type of string", message)
	lines := strings.Split(strings.TrimSpace(message.(string)), "\n")

	require.Lenf(t, lines, len(wantLines), "actual log lines length should match expected length, actual lines:\n\n%s", strings.Join(lines, "\n"))
	for i := range wantLines {
		require.Containsf(t, lines[i], wantLines[i], "log line at index %d should have contained expected output", i)
	}
}