// Copyright 2022 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) require.NotNil(t, r) require.True(t, r.NamespaceScoped()) require.Equal(t, []string{"pinniped"}, r.Categories()) 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" 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{ 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{ 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{ 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{ 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`, }, }, { 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, 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{ 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{ 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{ 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{ 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{ 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{ 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, ) 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(p []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) } }