diff --git a/pkg/ext/apiserver.go b/pkg/ext/apiserver.go index 3b7cd87f..86544864 100644 --- a/pkg/ext/apiserver.go +++ b/pkg/ext/apiserver.go @@ -239,20 +239,22 @@ func InstallStore[T runtime.Object, TList runtime.Object]( apiGroup.VersionedResourcesStorageMap[gvk.Version] = make(map[string]rest.Storage) } - delegate := &delegate[T, TList]{ - scheme: s.scheme, + delegate := &delegateError[T, TList]{ + inner: &delegate[T, TList]{ + scheme: s.scheme, - t: t, - tList: tList, - singularName: singularName, - gvk: gvk, - gvr: schema.GroupVersionResource{ - Group: gvk.Group, - Version: gvk.Version, - Resource: resourceName, + t: t, + tList: tList, + singularName: singularName, + gvk: gvk, + gvr: schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: resourceName, + }, + authorizer: s.authorizer, + store: store, }, - authorizer: s.authorizer, - store: store, } apiGroup.VersionedResourcesStorageMap[gvk.Version][resourceName] = delegate diff --git a/pkg/ext/delegate.go b/pkg/ext/delegate.go index 4df1dfbf..56747775 100644 --- a/pkg/ext/delegate.go +++ b/pkg/ext/delegate.go @@ -2,6 +2,7 @@ package ext import ( "context" + "errors" "fmt" "sync" @@ -16,6 +17,10 @@ import ( "k8s.io/apiserver/pkg/registry/rest" ) +var ( + errMissingUserInfo error = errors.New("missing user info") +) + // delegate is the bridge between k8s.io/apiserver's [rest.Storage] interface and // our own Store interface we want developers to use // @@ -328,7 +333,7 @@ func (s *delegate[T, TList]) GetSingularName() string { func (s *delegate[T, TList]) makeContext(parentCtx context.Context) (Context, error) { userInfo, ok := request.UserFrom(parentCtx) if !ok { - return Context{}, fmt.Errorf("missing user info") + return Context{}, errMissingUserInfo } ctx := Context{ diff --git a/pkg/ext/delegate_error.go b/pkg/ext/delegate_error.go new file mode 100644 index 00000000..1fa6e6e6 --- /dev/null +++ b/pkg/ext/delegate_error.go @@ -0,0 +1,110 @@ +package ext + +import ( + "context" + + "k8s.io/apimachinery/pkg/api/errors" + metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/apiserver/pkg/registry/rest" +) + +// delegateError wraps an inner delegate and converts unknown errors. +type delegateError[T runtime.Object, TList runtime.Object] struct { + inner *delegate[T, TList] +} + +func (d *delegateError[T, TList]) convertError(err error) error { + if _, ok := err.(errors.APIStatus); ok { + return err + } + + return errors.NewInternalError(err) +} + +func (d *delegateError[T, TList]) New() runtime.Object { + return d.inner.New() +} + +func (d *delegateError[T, TList]) Destroy() { + d.inner.Destroy() +} + +func (d *delegateError[T, TList]) NewList() runtime.Object { + return d.inner.NewList() +} + +func (d *delegateError[T, TList]) List(parentCtx context.Context, internaloptions *metainternalversion.ListOptions) (runtime.Object, error) { + result, err := d.inner.List(parentCtx, internaloptions) + if err != nil { + return nil, d.convertError(err) + } + return result, nil +} + +func (d *delegateError[T, TList]) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + result, err := d.inner.ConvertToTable(ctx, object, tableOptions) + if err != nil { + return nil, d.convertError(err) + } + return result, nil +} + +func (d *delegateError[T, TList]) Get(parentCtx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + result, err := d.inner.Get(parentCtx, name, options) + if err != nil { + return nil, d.convertError(err) + } + return result, nil +} + +func (d *delegateError[T, TList]) Delete(parentCtx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + result, completed, err := d.inner.Delete(parentCtx, name, deleteValidation, options) + if err != nil { + return nil, false, d.convertError(err) + } + return result, completed, nil +} + +func (d *delegateError[T, TList]) Create(parentCtx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { + result, err := d.inner.Create(parentCtx, obj, createValidation, options) + if err != nil { + return nil, d.convertError(err) + } + return result, nil +} + +func (d *delegateError[T, TList]) Update(parentCtx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { + result, created, err := d.inner.Update(parentCtx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options) + if err != nil { + return nil, false, d.convertError(err) + } + return result, created, nil +} + +func (d *delegateError[T, TList]) Watch(parentCtx context.Context, internaloptions *metainternalversion.ListOptions) (watch.Interface, error) { + result, err := d.inner.Watch(parentCtx, internaloptions) + if err != nil { + return nil, d.convertError(err) + } + return result, nil +} + +func (d *delegateError[T, TList]) GroupVersionKind(groupVersion schema.GroupVersion) schema.GroupVersionKind { + return d.inner.GroupVersionKind(groupVersion) +} + +func (d *delegateError[T, TList]) NamespaceScoped() bool { + return d.inner.NamespaceScoped() +} + +func (d *delegateError[T, TList]) Kind() string { + return d.inner.Kind() +} + +func (d *delegateError[T, TList]) GetSingularName() string { + return d.inner.GetSingularName() +} diff --git a/pkg/ext/delegate_error_test.go b/pkg/ext/delegate_error_test.go new file mode 100644 index 00000000..c61b24e2 --- /dev/null +++ b/pkg/ext/delegate_error_test.go @@ -0,0 +1,60 @@ +package ext + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestDelegateError_convertError(t *testing.T) { + tests := []struct { + name string + input error + output error + }{ + { + name: "api status error", + input: &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusNotFound, + Reason: metav1.StatusReasonNotFound, + }, + }, + output: &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusNotFound, + Reason: metav1.StatusReasonNotFound, + }, + }, + }, + { + name: "generic error", + input: assert.AnError, + output: &apierrors.StatusError{ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: http.StatusInternalServerError, + Reason: metav1.StatusReasonInternalError, + Details: &metav1.StatusDetails{ + Causes: []metav1.StatusCause{{Message: assert.AnError.Error()}}, + }, + Message: fmt.Sprintf("Internal error occurred: %v", assert.AnError), + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + delegateError := delegateError[*TestType, *TestTypeList]{ + inner: &delegate[*TestType, *TestTypeList]{}, + } + + output := delegateError.convertError(tt.input) + assert.Equal(t, tt.output, output) + }) + } + +} diff --git a/pkg/ext/delegate_test.go b/pkg/ext/delegate_test.go new file mode 100644 index 00000000..ba340a56 --- /dev/null +++ b/pkg/ext/delegate_test.go @@ -0,0 +1,1012 @@ +package ext + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + gomock "go.uber.org/mock/gomock" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/conversion" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/apiserver/pkg/registry/rest" +) + +func TestDelegate_Watch(t *testing.T) { + type input struct { + ctx context.Context + internaloptions *metainternalversion.ListOptions + } + + type output struct { + watch watch.Interface + err error + } + + type testCase struct { + name string + input input + expected output + storeSetup func(*MockStore[*TestType, *TestTypeList]) + simulateConvertionError bool + wantedErr bool + } + + tests := []testCase{ + { + name: "missing user in context", + input: input{ + ctx: context.TODO(), + internaloptions: &metainternalversion.ListOptions{}, + }, + expected: output{ + err: errMissingUserInfo, + }, + wantedErr: true, + storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) {}, + }, + { + name: "convert list error", + input: input{ + ctx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + internaloptions: &metainternalversion.ListOptions{}, + }, + simulateConvertionError: true, + wantedErr: true, + storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) {}, + }, + } + + for _, tt := range tests { + scheme := runtime.NewScheme() + addToSchemeTest(scheme) + + if !tt.simulateConvertionError { + scheme.AddConversionFunc(&metainternalversion.ListOptions{}, &metav1.ListOptions{}, convert_internalversion_ListOptions_to_v1_ListOptions) + } + + gvk := schema.GroupVersionKind{Group: "ext.cattle.io", Version: "v1", Kind: "TestType"} + gvr := schema.GroupVersionResource{Group: "ext.cattle.io", Version: "v1", Resource: "testtypes"} + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := NewMockStore[*TestType, *TestTypeList](ctrl) + + deleg4te := &delegate[*TestType, *TestTypeList]{ + scheme: scheme, + t: &TestType{}, + tList: &TestTypeList{}, + gvk: gvk, + gvr: gvr, + store: mockStore, + } + + watch, err := deleg4te.Watch(tt.input.ctx, tt.input.internaloptions) + if tt.wantedErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, watch, tt.expected.watch) + } + } +} + +func TestDelegate_Update(t *testing.T) { + type input struct { + parentCtx context.Context + name string + objInfo rest.UpdatedObjectInfo + createValidation rest.ValidateObjectFunc + updateValidation rest.ValidateObjectUpdateFunc + forceAllowCreate bool + options *metav1.UpdateOptions + } + + type output struct { + obj runtime.Object + created bool + err error + } + + type testCase struct { + name string + setup func(*MockUpdatedObjectInfo, *MockStore[*TestType, *TestTypeList]) + input input + expect output + wantErr bool + } + + tests := []testCase{ + { + name: "working case", + input: input{ + parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-user", + // objInfo is created in the for loop + forceAllowCreate: false, + options: &metav1.UpdateOptions{}, + }, + expect: output{ + obj: &TestType{}, + created: false, + err: nil, + }, + setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { + objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + store.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + }, + wantErr: false, + }, + { + name: "missing user in context", + input: input{ + parentCtx: context.TODO(), + }, + setup: func(muoi *MockUpdatedObjectInfo, ms *MockStore[*TestType, *TestTypeList]) {}, + expect: output{ + obj: nil, + created: false, + err: errMissingUserInfo, + }, + wantErr: true, + }, + { + name: "get failed - other error", + input: input{ + parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-user", + }, + expect: output{ + obj: nil, + created: false, + err: assert.AnError, + }, + setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { + store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, assert.AnError) + }, + wantErr: true, + }, + { + name: "get failed - not found - updated object error", + input: input{ + parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-user", + }, + expect: output{ + obj: nil, + created: false, + err: assert.AnError, + }, + setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { + store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, + &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusNotFound, + Reason: metav1.StatusReasonNotFound, + }, + }, + ) + objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(nil, assert.AnError) + }, + wantErr: true, + }, + { + name: "get failed - not found - create succeeded", + input: input{ + parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-user", + createValidation: func(ctx context.Context, obj runtime.Object) error { return nil }, + }, + expect: output{ + obj: &TestType{}, + created: true, + err: nil, + }, + setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { + store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, + &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusNotFound, + Reason: metav1.StatusReasonNotFound, + }, + }, + ) + objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + }, + wantErr: false, + }, + { + name: "get failed - not found - create validation error", + input: input{ + parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-user", + createValidation: func(ctx context.Context, obj runtime.Object) error { + return assert.AnError + }, + }, + expect: output{ + obj: nil, + created: false, + err: assert.AnError, + }, + setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { + store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, + &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusNotFound, + Reason: metav1.StatusReasonNotFound, + }, + }, + ) + objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + }, + wantErr: true, + }, + { + name: "get failed - not found - type error", + input: input{ + parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-user", + createValidation: func(ctx context.Context, obj runtime.Object) error { + return nil + }, + }, + expect: output{ + obj: nil, + created: false, + err: assert.AnError, + }, + setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { + store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, + &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusNotFound, + Reason: metav1.StatusReasonNotFound, + }, + }, + ) + objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(&runtime.Unknown{}, nil) + + }, + wantErr: true, + }, + { + name: "get failed - not found - store create error", + input: input{ + parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-user", + createValidation: func(ctx context.Context, obj runtime.Object) error { + return nil + }, + }, + expect: output{ + obj: nil, + created: false, + err: assert.AnError, + }, + setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { + store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, + &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusNotFound, + Reason: metav1.StatusReasonNotFound, + }, + }, + ) + store.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, assert.AnError) + objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + + }, + wantErr: true, + }, + { + name: "get worked - updated object error", + input: input{ + parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-user", + createValidation: func(ctx context.Context, obj runtime.Object) error { + return nil + }, + }, + expect: output{ + obj: nil, + created: false, + err: assert.AnError, + }, + setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { + store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(nil, assert.AnError) + + }, + wantErr: true, + }, + { + name: "get worked - type error", + input: input{ + parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-user", + createValidation: func(ctx context.Context, obj runtime.Object) error { + return nil + }, + }, + expect: output{ + obj: nil, + created: false, + err: assert.AnError, + }, + setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { + store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(&runtime.Unknown{}, nil) + + }, + wantErr: true, + }, + { + name: "get worked - update validation error", + input: input{ + parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-user", + createValidation: func(ctx context.Context, obj runtime.Object) error { + return nil + }, + updateValidation: func(ctx context.Context, obj, old runtime.Object) error { + return assert.AnError + }, + }, + expect: output{ + obj: nil, + created: false, + err: assert.AnError, + }, + setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { + store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + + }, + wantErr: true, + }, + { + name: "get worked - store update error", + input: input{ + parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-user", + createValidation: func(ctx context.Context, obj runtime.Object) error { + return nil + }, + }, + expect: output{ + obj: nil, + created: false, + err: assert.AnError, + }, + setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { + store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + store.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, assert.AnError) + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + addToSchemeTest(scheme) + + gvk := schema.GroupVersionKind{Group: "ext.cattle.io", Version: "v1", Kind: "TestType"} + gvr := schema.GroupVersionResource{Group: "ext.cattle.io", Version: "v1", Resource: "testtypes"} + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := NewMockStore[*TestType, *TestTypeList](ctrl) + mockObjInfo := NewMockUpdatedObjectInfo(ctrl) + tt.input.objInfo = mockObjInfo + tt.setup(mockObjInfo, mockStore) + + deleg4te := &delegate[*TestType, *TestTypeList]{ + scheme: scheme, + t: &TestType{}, + tList: &TestTypeList{}, + gvk: gvk, + gvr: gvr, + store: mockStore, + } + + obj, created, err := deleg4te.Update(tt.input.parentCtx, tt.input.name, tt.input.objInfo, tt.input.createValidation, tt.input.updateValidation, tt.input.forceAllowCreate, tt.input.options) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expect.obj, obj) + assert.Equal(t, tt.expect.created, created) + } + }) + } + +} + +func TestDelegate_Create(t *testing.T) { + type input struct { + ctx context.Context + obj runtime.Object + createValidation rest.ValidateObjectFunc + options *metav1.CreateOptions + } + + type output struct { + createResult runtime.Object + err error + } + + type testCase struct { + name string + storeSetup func(*MockStore[*TestType, *TestTypeList]) + wantErr bool + input input + expect output + } + + tests := []testCase{ + { + name: "working case", + storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { + ms.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + }, + input: input{ + ctx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + obj: &TestType{}, + options: &metav1.CreateOptions{}, + }, + expect: output{ + createResult: &TestType{}, + err: nil, + }, + wantErr: false, + }, + { + name: "missing user in the context", + storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) {}, + input: input{ + ctx: context.Background(), + obj: &TestType{}, + options: &metav1.CreateOptions{}, + }, + expect: output{ + createResult: nil, + err: errMissingUserInfo, + }, + wantErr: true, + }, + { + name: "store create error", + storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { + ms.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, assert.AnError) + }, + input: input{ + ctx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + obj: &TestType{}, + options: &metav1.CreateOptions{}, + }, + expect: output{ + createResult: nil, + err: assert.AnError, + }, + wantErr: true, + }, + { + name: "wrong type error", + storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) {}, + input: input{ + ctx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + obj: &runtime.Unknown{}, + options: &metav1.CreateOptions{}, + }, + expect: output{ + createResult: nil, + err: assert.AnError, + }, + wantErr: true, + }, + { + name: "create validation error", + storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) {}, + input: input{ + ctx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + obj: &TestType{}, + createValidation: func(ctx context.Context, obj runtime.Object) error { + return assert.AnError + }, + options: &metav1.CreateOptions{}, + }, + expect: output{ + createResult: &TestType{}, + err: assert.AnError, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + addToSchemeTest(scheme) + + gvk := schema.GroupVersionKind{Group: "ext.cattle.io", Version: "v1", Kind: "TestType"} + gvr := schema.GroupVersionResource{Group: "ext.cattle.io", Version: "v1", Resource: "testtypes"} + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := NewMockStore[*TestType, *TestTypeList](ctrl) + tt.storeSetup(mockStore) + + deleg4te := &delegate[*TestType, *TestTypeList]{ + scheme: scheme, + t: &TestType{}, + tList: &TestTypeList{}, + gvk: gvk, + gvr: gvr, + store: mockStore, + } + + result, err := deleg4te.Create(tt.input.ctx, tt.input.obj, tt.input.createValidation, tt.input.options) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expect.createResult, result) + } + }) + } +} + +func TestDelegate_Delete(t *testing.T) { + type input struct { + ctx context.Context + name string + deleteValidation rest.ValidateObjectFunc + options *metav1.DeleteOptions + } + + type output struct { + deleteResult runtime.Object + completed bool + err error + } + + type testCase struct { + name string + storeSetup func(*MockStore[*TestType, *TestTypeList]) + wantErr bool + input input + expect output + } + + tests := []testCase{ + { + name: "working case", + storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { + ms.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + ms.EXPECT().Delete(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + }, + input: input{ + ctx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-object", + options: &metav1.DeleteOptions{}, + }, + expect: output{ + deleteResult: &TestType{}, + completed: true, + err: nil, + }, + wantErr: false, + }, + { + name: "missing user in the context", + storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) {}, + input: input{ + ctx: context.Background(), + name: "test-object", + options: &metav1.DeleteOptions{}, + }, + expect: output{ + deleteResult: nil, + completed: false, + err: errMissingUserInfo, + }, + wantErr: true, + }, + { + name: "store get error", + storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { + ms.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, assert.AnError) + }, + input: input{ + ctx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-object", + options: &metav1.DeleteOptions{}, + }, + expect: output{ + deleteResult: nil, + completed: false, + err: assert.AnError, + }, + wantErr: true, + }, + { + name: "store delete error", + storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { + ms.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + ms.EXPECT().Delete(gomock.Any(), gomock.Any(), gomock.Any()).Return(assert.AnError) + }, + input: input{ + ctx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-object", + options: &metav1.DeleteOptions{}, + }, + expect: output{ + deleteResult: &TestType{}, + completed: true, + err: assert.AnError, + }, + wantErr: true, + }, + { + name: "delete validation error", + storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { + ms.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + }, + input: input{ + ctx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-object", + deleteValidation: func(ctx context.Context, obj runtime.Object) error { return assert.AnError }, + options: &metav1.DeleteOptions{}, + }, + expect: output{ + deleteResult: nil, + completed: false, + err: assert.AnError, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + addToSchemeTest(scheme) + + gvk := schema.GroupVersionKind{Group: "ext.cattle.io", Version: "v1", Kind: "TestType"} + gvr := schema.GroupVersionResource{Group: "ext.cattle.io", Version: "v1", Resource: "testtypes"} + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := NewMockStore[*TestType, *TestTypeList](ctrl) + tt.storeSetup(mockStore) + + deleg4te := &delegate[*TestType, *TestTypeList]{ + scheme: scheme, + t: &TestType{}, + tList: &TestTypeList{}, + gvk: gvk, + gvr: gvr, + store: mockStore, + } + + result, completed, err := deleg4te.Delete(tt.input.ctx, tt.input.name, tt.input.deleteValidation, tt.input.options) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expect.deleteResult, result) + assert.Equal(t, tt.expect.completed, completed) + } + }) + } +} + +func TestDelegate_Get(t *testing.T) { + type input struct { + ctx context.Context + name string + options *metav1.GetOptions + } + + type output struct { + getResult runtime.Object + err error + } + + type testCase struct { + name string + storeSetup func(*MockStore[*TestType, *TestTypeList]) + wantErr bool + input input + expect output + } + + tests := []testCase{ + { + name: "working case", + storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { + ms.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) + }, + input: input{ + ctx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-object", + options: &metav1.GetOptions{}, + }, + expect: output{ + getResult: &TestType{}, + err: nil, + }, + wantErr: false, + }, + { + name: "missing user in the context", + storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) {}, + input: input{ + ctx: context.Background(), + name: "testing-obj", + options: &metav1.GetOptions{}, + }, + expect: output{ + getResult: nil, + err: errMissingUserInfo, + }, + wantErr: true, + }, + { + name: "store get error", + storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { + ms.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, assert.AnError) + }, + input: input{ + ctx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + name: "test-object", + options: &metav1.GetOptions{}, + }, + expect: output{ + getResult: &TestType{}, + err: assert.AnError, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + addToSchemeTest(scheme) + + gvk := schema.GroupVersionKind{Group: "ext.cattle.io", Version: "v1", Kind: "TestType"} + gvr := schema.GroupVersionResource{Group: "ext.cattle.io", Version: "v1", Resource: "testtypes"} + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := NewMockStore[*TestType, *TestTypeList](ctrl) + tt.storeSetup(mockStore) + + deleg4te := &delegate[*TestType, *TestTypeList]{ + scheme: scheme, + t: &TestType{}, + tList: &TestTypeList{}, + gvk: gvk, + gvr: gvr, + store: mockStore, + } + + result, err := deleg4te.Get(tt.input.ctx, tt.input.name, tt.input.options) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expect.getResult, result) + } + + }) + } +} + +func TestDelegate_List(t *testing.T) { + type input struct { + ctx context.Context + listOptions *metainternalversion.ListOptions + } + + type output struct { + listResult runtime.Object + err error + } + + type testCase struct { + name string + storeSetup func(*MockStore[*TestType, *TestTypeList]) + simulateConvertError bool + wantErr bool + input input + expect output + } + + tests := []testCase{ + { + name: "working case, for completion reasons", + input: input{ + ctx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + listOptions: &metainternalversion.ListOptions{}, + }, + storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { + ms.EXPECT().List(gomock.Any(), gomock.Any()).Return(&TestTypeList{}, nil) + }, + wantErr: false, + expect: output{ + listResult: &TestTypeList{}, + err: nil, + }, + }, + { + name: "missing user in the context", + input: input{ + ctx: context.Background(), + listOptions: &metainternalversion.ListOptions{}, + }, + storeSetup: func(mockStore *MockStore[*TestType, *TestTypeList]) {}, + wantErr: true, + expect: output{ + listResult: nil, + err: errMissingUserInfo, + }, + }, + { + name: "convertListOptions error", + input: input{ + ctx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + listOptions: &metainternalversion.ListOptions{}, + }, + storeSetup: func(mockStore *MockStore[*TestType, *TestTypeList]) {}, + simulateConvertError: true, + wantErr: true, + expect: output{ + listResult: nil, + err: assert.AnError, + }, + }, + { + name: "error returned by store", + input: input{ + ctx: request.WithUser(context.Background(), &user.DefaultInfo{ + Name: "test-user", + }), + listOptions: &metainternalversion.ListOptions{}, + }, + storeSetup: func(mockStore *MockStore[*TestType, *TestTypeList]) { + mockStore.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, assert.AnError) + }, + wantErr: true, + expect: output{ + listResult: nil, + err: assert.AnError, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + addToSchemeTest(scheme) + + if !tt.simulateConvertError { + scheme.AddConversionFunc(&metainternalversion.ListOptions{}, &metav1.ListOptions{}, convert_internalversion_ListOptions_to_v1_ListOptions) + } + + gvk := schema.GroupVersionKind{Group: "ext.cattle.io", Version: "v1", Kind: "TestType"} + gvr := schema.GroupVersionResource{Group: "ext.cattle.io", Version: "v1", Resource: "testtypes"} + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := NewMockStore[*TestType, *TestTypeList](ctrl) + tt.storeSetup(mockStore) + + deleg4te := &delegate[*TestType, *TestTypeList]{ + scheme: scheme, + t: &TestType{}, + tList: &TestTypeList{}, + gvk: gvk, + gvr: gvr, + store: mockStore, + } + + result, err := deleg4te.List(tt.input.ctx, tt.input.listOptions) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expect.listResult, result) + } + }) + } +} + +func convert_internalversion_ListOptions_to_v1_ListOptions(in, out interface{}, s conversion.Scope) error { + i, ok := in.(*metainternalversion.ListOptions) + if !ok { + return errors.New("cannot convert in param into internalversion.ListOptions") + } + o, ok := out.(*metav1.ListOptions) + if !ok { + return errors.New("cannot convert out param into metav1.ListOptions") + } + if i.LabelSelector != nil { + o.LabelSelector = i.LabelSelector.String() + } + if i.FieldSelector != nil { + o.FieldSelector = i.FieldSelector.String() + } + o.Watch = i.Watch + o.ResourceVersion = i.ResourceVersion + o.TimeoutSeconds = i.TimeoutSeconds + return nil +} diff --git a/pkg/ext/rest_mock.go b/pkg/ext/rest_mock.go new file mode 100644 index 00000000..40b13fce --- /dev/null +++ b/pkg/ext/rest_mock.go @@ -0,0 +1,66 @@ +// Code generated by MockGen. DO NOT EDIT. + +// Package ext is a generated GoMock package. +package ext + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// MockUpdatedObjectInfo is a mock of UpdatedObjectInfo interface. +type MockUpdatedObjectInfo struct { + ctrl *gomock.Controller + recorder *MockUpdatedObjectInfoMockRecorder + isgomock struct{} +} + +// MockUpdatedObjectInfoMockRecorder is the mock recorder for MockUpdatedObjectInfo. +type MockUpdatedObjectInfoMockRecorder struct { + mock *MockUpdatedObjectInfo +} + +// NewMockUpdatedObjectInfo creates a new mock instance. +func NewMockUpdatedObjectInfo(ctrl *gomock.Controller) *MockUpdatedObjectInfo { + mock := &MockUpdatedObjectInfo{ctrl: ctrl} + mock.recorder = &MockUpdatedObjectInfoMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockUpdatedObjectInfo) EXPECT() *MockUpdatedObjectInfoMockRecorder { + return m.recorder +} + +// Preconditions mocks base method. +func (m *MockUpdatedObjectInfo) Preconditions() *v1.Preconditions { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Preconditions") + ret0, _ := ret[0].(*v1.Preconditions) + return ret0 +} + +// Preconditions indicates an expected call of Preconditions. +func (mr *MockUpdatedObjectInfoMockRecorder) Preconditions() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Preconditions", reflect.TypeOf((*MockUpdatedObjectInfo)(nil).Preconditions)) +} + +// UpdatedObject mocks base method. +func (m *MockUpdatedObjectInfo) UpdatedObject(ctx context.Context, oldObj runtime.Object) (runtime.Object, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdatedObject", ctx, oldObj) + ret0, _ := ret[0].(runtime.Object) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdatedObject indicates an expected call of UpdatedObject. +func (mr *MockUpdatedObjectInfoMockRecorder) UpdatedObject(ctx, oldObj any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatedObject", reflect.TypeOf((*MockUpdatedObjectInfo)(nil).UpdatedObject), ctx, oldObj) +} diff --git a/pkg/ext/store_mock.go b/pkg/ext/store_mock.go new file mode 100644 index 00000000..159d98ed --- /dev/null +++ b/pkg/ext/store_mock.go @@ -0,0 +1,131 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./pkg/ext/store.go +// +// Generated by this command: +// +// mockgen -source=./pkg/ext/store.go -destination=./pkg/ext/store_mock.go -package=ext +// + +// Package ext is a generated GoMock package. +package ext + +import ( + reflect "reflect" + + gomock "go.uber.org/mock/gomock" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// MockStore is a mock of Store interface. +type MockStore[T runtime.Object, TList runtime.Object] struct { + ctrl *gomock.Controller + recorder *MockStoreMockRecorder[T, TList] + isgomock struct{} +} + +// MockStoreMockRecorder is the mock recorder for MockStore. +type MockStoreMockRecorder[T runtime.Object, TList runtime.Object] struct { + mock *MockStore[T, TList] +} + +// NewMockStore creates a new mock instance. +func NewMockStore[T runtime.Object, TList runtime.Object](ctrl *gomock.Controller) *MockStore[T, TList] { + mock := &MockStore[T, TList]{ctrl: ctrl} + mock.recorder = &MockStoreMockRecorder[T, TList]{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStore[T, TList]) EXPECT() *MockStoreMockRecorder[T, TList] { + return m.recorder +} + +// Create mocks base method. +func (m *MockStore[T, TList]) Create(ctx Context, obj T, opts *v1.CreateOptions) (T, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx, obj, opts) + ret0, _ := ret[0].(T) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockStoreMockRecorder[T, TList]) Create(ctx, obj, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockStore[T, TList])(nil).Create), ctx, obj, opts) +} + +// Delete mocks base method. +func (m *MockStore[T, TList]) Delete(ctx Context, name string, opts *v1.DeleteOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, name, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockStoreMockRecorder[T, TList]) Delete(ctx, name, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockStore[T, TList])(nil).Delete), ctx, name, opts) +} + +// Get mocks base method. +func (m *MockStore[T, TList]) Get(ctx Context, name string, opts *v1.GetOptions) (T, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", ctx, name, opts) + ret0, _ := ret[0].(T) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockStoreMockRecorder[T, TList]) Get(ctx, name, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStore[T, TList])(nil).Get), ctx, name, opts) +} + +// List mocks base method. +func (m *MockStore[T, TList]) List(ctx Context, opts *v1.ListOptions) (TList, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", ctx, opts) + ret0, _ := ret[0].(TList) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockStoreMockRecorder[T, TList]) List(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockStore[T, TList])(nil).List), ctx, opts) +} + +// Update mocks base method. +func (m *MockStore[T, TList]) Update(ctx Context, obj T, opts *v1.UpdateOptions) (T, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", ctx, obj, opts) + ret0, _ := ret[0].(T) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockStoreMockRecorder[T, TList]) Update(ctx, obj, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockStore[T, TList])(nil).Update), ctx, obj, opts) +} + +// Watch mocks base method. +func (m *MockStore[T, TList]) Watch(ctx Context, opts *v1.ListOptions) (<-chan WatchEvent[T], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Watch", ctx, opts) + ret0, _ := ret[0].(<-chan WatchEvent[T]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Watch indicates an expected call of Watch. +func (mr *MockStoreMockRecorder[T, TList]) Watch(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Watch", reflect.TypeOf((*MockStore[T, TList])(nil).Watch), ctx, opts) +}