diff --git a/workspaces/backend/go.mod b/workspaces/backend/go.mod index ab546a803..53a7def92 100644 --- a/workspaces/backend/go.mod +++ b/workspaces/backend/go.mod @@ -60,6 +60,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.19.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect @@ -93,6 +94,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect google.golang.org/grpc v1.65.0 // indirect google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/workspaces/backend/internal/auth/authentication_test.go b/workspaces/backend/internal/auth/authentication_test.go new file mode 100644 index 000000000..7af98a9bb --- /dev/null +++ b/workspaces/backend/internal/auth/authentication_test.go @@ -0,0 +1,106 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package auth + +import ( + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("NewRequestAuthenticator", func() { + const ( + userHeader = "X-User" + groupsHeader = "X-Groups" + userPrefix = "service-account:" + ) + + type authTestInput struct { + userHeaderValue string + groupsHeaderValue string + userPrefix string + expectAuthenticated bool + expectedUserName string + expectedGroups []string + } + + runAuthTest := func(input authTestInput) { + authn, err := NewRequestAuthenticator(userHeader, input.userPrefix, groupsHeader) + Expect(err).NotTo(HaveOccurred()) + + req, _ := http.NewRequest("GET", "/", http.NoBody) + if input.userHeaderValue != "" { + req.Header.Set(userHeader, input.userHeaderValue) + } + if input.groupsHeaderValue != "" { + req.Header.Set(groupsHeader, input.groupsHeaderValue) + } + + resp, ok, err := authn.AuthenticateRequest(req) + Expect(err).NotTo(HaveOccurred()) + Expect(ok).To(Equal(input.expectAuthenticated)) + if input.expectAuthenticated { + Expect(resp).NotTo(BeNil()) + Expect(resp.User.GetName()).To(Equal(input.expectedUserName)) + Expect(resp.User.GetGroups()).To(Equal(input.expectedGroups)) + } else { + Expect(resp).To(BeNil()) + } + } + It("authenticates user without prefix", func() { + runAuthTest(authTestInput{ + userHeaderValue: "test-user", + groupsHeaderValue: "group-a,group-b", + userPrefix: "", + expectAuthenticated: true, + expectedUserName: "test-user", + expectedGroups: []string{"group-a,group-b"}, + }) + }) + + It("authenticates user and trims prefix", func() { + runAuthTest(authTestInput{ + userHeaderValue: userPrefix + "test-user", + groupsHeaderValue: "group-c", + userPrefix: userPrefix, + expectAuthenticated: true, + expectedUserName: "test-user", + expectedGroups: []string{"group-c"}, + }) + }) + + It("authenticates user when prefix is configured but not present", func() { + runAuthTest(authTestInput{ + userHeaderValue: "another-user", + groupsHeaderValue: "", + userPrefix: userPrefix, + expectAuthenticated: true, + expectedUserName: "another-user", + expectedGroups: []string{}, + }) + }) + + It("handles unauthenticated request", func() { + runAuthTest(authTestInput{ + userHeaderValue: "", + groupsHeaderValue: "some-group", + userPrefix: userPrefix, + expectAuthenticated: false, + }) + }) +}) diff --git a/workspaces/backend/internal/auth/authorization_test.go b/workspaces/backend/internal/auth/authorization_test.go new file mode 100644 index 000000000..77ffb1636 --- /dev/null +++ b/workspaces/backend/internal/auth/authorization_test.go @@ -0,0 +1,131 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package auth + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authentication/user" +) + +type mockObject struct { + metav1.ObjectMeta + metav1.TypeMeta +} + +func (m *mockObject) GetObjectKind() schema.ObjectKind { return &m.TypeMeta } + +func (m *mockObject) DeepCopyObject() runtime.Object { + return &mockObject{ + ObjectMeta: *m.ObjectMeta.DeepCopy(), + TypeMeta: m.TypeMeta, + } +} + +var _ runtime.Object = &mockObject{} +var _ = Describe("NewResourcePolicy", func() { + It("creates policy for a namespaced resource", func() { + mock := &mockObject{} + mock.SetName("my-deployment") + mock.SetNamespace("my-namespace") + mock.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }) + + policy := NewResourcePolicy(ResourceVerbGet, mock) + + Expect(policy).NotTo(BeNil()) + Expect(policy.Verb).To(Equal(ResourceVerbGet)) + Expect(policy.Group).To(Equal("apps")) + Expect(policy.Version).To(Equal("v1")) + Expect(policy.Kind).To(Equal("Deployment")) + Expect(policy.Namespace).To(Equal("my-namespace")) + Expect(policy.Name).To(Equal("my-deployment")) + }) + + It("creates policy for a cluster-scoped resource", func() { + mock := &mockObject{} + mock.SetName("my-cluster-role") + mock.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + }) + + policy := NewResourcePolicy(ResourceVerbDelete, mock) + + Expect(policy).NotTo(BeNil()) + Expect(policy.Verb).To(Equal(ResourceVerbDelete)) + Expect(policy.Group).To(Equal("rbac.authorization.k8s.io")) + Expect(policy.Kind).To(Equal("ClusterRole")) + Expect(policy.Name).To(Equal("my-cluster-role")) + Expect(policy.Namespace).To(BeEmpty()) + }) +}) + +var _ = Describe("AttributesFor", func() { + userInfo := &user.DefaultInfo{ + Name: "test-user", + Groups: []string{"group-a", "system:authenticated"}, + } + + It("creates attributes for a specific resource", func() { + policy := &ResourcePolicy{ + Verb: ResourceVerbUpdate, + Group: "kubeflow.org", + Version: "v1beta1", + Kind: "Workspace", + Namespace: "user-namespace", + Name: "my-workspace", + } + + attrs := policy.AttributesFor(userInfo) + Expect(attrs).NotTo(BeNil()) + Expect(attrs.GetUser()).To(Equal(userInfo)) + Expect(attrs.GetVerb()).To(Equal("update")) + Expect(attrs.GetNamespace()).To(Equal("user-namespace")) + Expect(attrs.GetAPIGroup()).To(Equal("kubeflow.org")) + Expect(attrs.GetAPIVersion()).To(Equal("v1beta1")) + Expect(attrs.GetResource()).To(Equal("Workspace")) + Expect(attrs.GetName()).To(Equal("my-workspace")) + Expect(attrs.IsResourceRequest()).To(BeTrue()) + }) + + It("creates attributes for a collection of resources", func() { + policy := &ResourcePolicy{ + Verb: ResourceVerbList, + Group: "kubeflow.org", + Version: "v1beta1", + Kind: "Workspace", + Namespace: "user-namespace", + Name: "", + } + + attrs := policy.AttributesFor(userInfo) + Expect(attrs).NotTo(BeNil()) + Expect(attrs.GetUser()).To(Equal(userInfo)) + Expect(attrs.GetVerb()).To(Equal("list")) + Expect(attrs.GetNamespace()).To(Equal("user-namespace")) + Expect(attrs.GetName()).To(BeEmpty()) + Expect(attrs.IsResourceRequest()).To(BeTrue()) + }) +}) diff --git a/workspaces/backend/internal/auth/suite_test.go b/workspaces/backend/internal/auth/suite_test.go new file mode 100644 index 000000000..2815c9673 --- /dev/null +++ b/workspaces/backend/internal/auth/suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package auth + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAuth(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Auth Suite") +} diff --git a/workspaces/backend/internal/helper/k8s_test.go b/workspaces/backend/internal/helper/k8s_test.go new file mode 100644 index 000000000..e68a4fbe6 --- /dev/null +++ b/workspaces/backend/internal/helper/k8s_test.go @@ -0,0 +1,44 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helper + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var _ = Describe("Helper functions", func() { + Describe("BuildScheme", func() { + It("should return a scheme that recognizes Pod and Workspace types", func() { + scheme, err := BuildScheme() + Expect(err).NotTo(HaveOccurred()) + Expect(scheme).NotTo(BeNil()) + + podGVK := corev1.SchemeGroupVersion.WithKind("Pod") + Expect(scheme.Recognizes(podGVK)).To(BeTrue()) + + workspaceGVK := schema.GroupVersionKind{ + Group: "kubeflow.org", + Version: "v1beta1", + Kind: "Workspace", + } + Expect(scheme.Recognizes(workspaceGVK)).To(BeTrue()) + }) + }) +}) diff --git a/workspaces/backend/internal/helper/suite_test.go b/workspaces/backend/internal/helper/suite_test.go new file mode 100644 index 000000000..d1d24b197 --- /dev/null +++ b/workspaces/backend/internal/helper/suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helper + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestHelper(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Helper Suite") +} diff --git a/workspaces/backend/internal/helper/validation_test.go b/workspaces/backend/internal/helper/validation_test.go new file mode 100644 index 000000000..99c87df2f --- /dev/null +++ b/workspaces/backend/internal/helper/validation_test.go @@ -0,0 +1,125 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helper + +import ( + "errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +var _ = Describe("Helper functions", func() { + + Describe("StatusCausesFromAPIStatus", func() { + var sampleCauses = []metav1.StatusCause{ + { + Type: metav1.CauseTypeFieldValueInvalid, + Message: "Invalid value", + Field: "spec.name", + }, + } + + It("should extract causes from a valid validation error", func() { + validationError := apierrors.NewInvalid( + kubefloworgv1beta1.GroupVersion.WithKind("Workspace").GroupKind(), + "my-workspace", + field.ErrorList{ + field.Invalid(field.NewPath("spec", "name"), "my-workspace-!@#", "invalid name"), + }, + ) + validationError.ErrStatus.Details.Causes = sampleCauses + + causes := StatusCausesFromAPIStatus(validationError) + Expect(causes).To(Equal(sampleCauses)) + }) + + It("should return nil for a non-validation APIStatus error", func() { + notFoundError := apierrors.NewNotFound( + kubefloworgv1beta1.GroupVersion.WithResource("Workspace").GroupResource(), + "my-workspace", + ) + causes := StatusCausesFromAPIStatus(notFoundError) + Expect(causes).To(BeNil()) + }) + + It("should return nil for a standard non-API error", func() { + standardError := errors.New("this is a standard error") + causes := StatusCausesFromAPIStatus(standardError) + Expect(causes).To(BeNil()) + }) + }) + + Describe("ValidateFieldIsNotEmpty", func() { + path := field.NewPath("test") + + It("should return no errors for a non-empty value", func() { + errs := ValidateFieldIsNotEmpty(path, "some-value") + Expect(errs).To(BeEmpty()) + }) + + It("should return a required error for an empty value", func() { + errs := ValidateFieldIsNotEmpty(path, "") + Expect(errs).To(HaveLen(1)) + Expect(errs[0].Type).To(Equal(field.ErrorTypeRequired)) + }) + }) + + Describe("ValidateFieldIsDNS1123Subdomain", func() { + path := field.NewPath("metadata", "name") + + DescribeTable("should validate DNS1123 subdomain", + func(value string, expectErr bool) { + errs := ValidateFieldIsDNS1123Subdomain(path, value) + if expectErr { + Expect(errs).NotTo(BeEmpty()) + } else { + Expect(errs).To(BeEmpty()) + } + }, + Entry("valid subdomain", "my-valid-subdomain", false), + Entry("valid subdomain with dots", "my.valid.subdomain", false), + Entry("empty value", "", true), + Entry("value with uppercase", "Invalid-Name", true), + Entry("value starting with hyphen", "-invalid", true), + ) + }) + + Describe("ValidateFieldIsDNS1123Label", func() { + path := field.NewPath("metadata", "namespace") + + DescribeTable("should validate DNS1123 label", + func(value string, expectErr bool) { + errs := ValidateFieldIsDNS1123Label(path, value) + if expectErr { + Expect(errs).NotTo(BeEmpty()) + } else { + Expect(errs).To(BeEmpty()) + } + }, + Entry("valid label", "my-valid-label", false), + Entry("empty value", "", true), + Entry("value with dots", "invalid.label", true), + Entry("value with uppercase", "Invalid-Label", true), + ) + }) +}) diff --git a/workspaces/backend/internal/repositories/workspacekinds/suite_test.go b/workspaces/backend/internal/repositories/workspacekinds/suite_test.go new file mode 100644 index 000000000..945a919e5 --- /dev/null +++ b/workspaces/backend/internal/repositories/workspacekinds/suite_test.go @@ -0,0 +1,127 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package workspacekinds + +import ( + "context" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestWorkspaceKinds(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "WorkspaceKinds Suite") +} + +func newTestWorkspaceKindRepository(initObjs ...client.Object) *WorkspaceKindRepository { + s := runtime.NewScheme() + _ = kubefloworgv1beta1.AddToScheme(s) + + cl := fake.NewClientBuilder().WithScheme(s).WithObjects(initObjs...).Build() + return NewWorkspaceKindRepository(cl) +} + +var _ = Describe("WorkspaceKindRepository", func() { + var ( + ctx context.Context + ) + + BeforeEach(func() { + ctx = context.Background() + }) + + Describe("GetWorkspaceKind", func() { + const kindName = "test-kind" + + It("returns the WorkspaceKind if it exists", func() { + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{ + ObjectMeta: metav1.ObjectMeta{Name: kindName}, + Spec: kubefloworgv1beta1.WorkspaceKindSpec{ + Spawner: kubefloworgv1beta1.WorkspaceKindSpawner{ + DisplayName: "Test Workspace Kind", + }, + }, + } + + repo := newTestWorkspaceKindRepository(workspaceKind) + got, err := repo.GetWorkspaceKind(ctx, kindName) + + Expect(err).ToNot(HaveOccurred()) + Expect(got.Name).To(Equal(kindName)) + Expect(got.DisplayName).To(Equal("Test Workspace Kind")) + }) + + It("returns an error if the WorkspaceKind is not found", func() { + repo := newTestWorkspaceKindRepository() + _, err := repo.GetWorkspaceKind(ctx, "non-existent") + + Expect(err).To(MatchError(ErrWorkspaceKindNotFound)) + }) + }) + + Describe("GetWorkspaceKinds", func() { + It("returns all WorkspaceKinds", func() { + workspaceKind1 := &kubefloworgv1beta1.WorkspaceKind{ + ObjectMeta: metav1.ObjectMeta{Name: "kind-1"}, + Spec: kubefloworgv1beta1.WorkspaceKindSpec{ + Spawner: kubefloworgv1beta1.WorkspaceKindSpawner{ + DisplayName: "Kind One", + }, + }, + } + workspaceKind2 := &kubefloworgv1beta1.WorkspaceKind{ + ObjectMeta: metav1.ObjectMeta{Name: "kind-2"}, + Spec: kubefloworgv1beta1.WorkspaceKindSpec{ + Spawner: kubefloworgv1beta1.WorkspaceKindSpawner{ + DisplayName: "Kind Two", + }, + }, + } + + repo := newTestWorkspaceKindRepository(workspaceKind1, workspaceKind2) + kinds, err := repo.GetWorkspaceKinds(ctx) + + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(HaveLen(2)) + + expected := []models.WorkspaceKind{ + models.NewWorkspaceKindModelFromWorkspaceKind(workspaceKind1), + models.NewWorkspaceKindModelFromWorkspaceKind(workspaceKind2), + } + Expect(kinds).To(ConsistOf(expected)) + }) + + It("returns an empty slice when no WorkspaceKinds exist", func() { + repo := newTestWorkspaceKindRepository() + kinds, err := repo.GetWorkspaceKinds(ctx) + + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(BeEmpty()) + }) + }) +}) diff --git a/workspaces/backend/internal/repositories/workspaces/suite_test.go b/workspaces/backend/internal/repositories/workspaces/suite_test.go new file mode 100644 index 000000000..2e9c8b73c --- /dev/null +++ b/workspaces/backend/internal/repositories/workspaces/suite_test.go @@ -0,0 +1,215 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package workspaces + +import ( + "context" + "testing" + + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestWorkspaceRepository(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "WorkspaceRepository Suite") +} + +func newTestScheme() *runtime.Scheme { + scheme := runtime.NewScheme() + _ = kubefloworgv1beta1.AddToScheme(scheme) + return scheme +} + +func newTestWorkspaceRepository(initObjs ...client.Object) *WorkspaceRepository { + scheme := newTestScheme() + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(initObjs...).Build() + return NewWorkspaceRepository(cl) +} + +var _ = Describe("WorkspaceRepository", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + }) + + Describe("GetWorkspace", func() { + var ( + namespace = "test-ns" + workspaceName = "test-ws" + workspaceKindName = "test-kind" + testWorkspaceKind *kubefloworgv1beta1.WorkspaceKind + testWorkspace *kubefloworgv1beta1.Workspace + ) + + BeforeEach(func() { + testWorkspaceKind = &kubefloworgv1beta1.WorkspaceKind{ + ObjectMeta: metav1.ObjectMeta{Name: workspaceKindName}, + } + testWorkspace = &kubefloworgv1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{Name: workspaceName, Namespace: namespace}, + Spec: kubefloworgv1beta1.WorkspaceSpec{Kind: workspaceKindName}, + } + }) + + It("returns the workspace and kind if both exist", func() { + r := newTestWorkspaceRepository(testWorkspace, testWorkspaceKind) + ws, err := r.GetWorkspace(ctx, namespace, workspaceName) + Expect(err).NotTo(HaveOccurred()) + Expect(ws).To(Equal(workspaces.NewWorkspaceModelFromWorkspace(testWorkspace, testWorkspaceKind))) + }) + + It("returns the workspace even if kind is missing", func() { + r := newTestWorkspaceRepository(testWorkspace) + ws, err := r.GetWorkspace(ctx, namespace, workspaceName) + Expect(err).NotTo(HaveOccurred()) + Expect(ws).To(Equal(workspaces.NewWorkspaceModelFromWorkspace(testWorkspace, &kubefloworgv1beta1.WorkspaceKind{}))) + }) + + It("returns an error if the workspace does not exist", func() { + r := newTestWorkspaceRepository() + _, err := r.GetWorkspace(ctx, namespace, "non-existent") + Expect(err).To(Equal(ErrWorkspaceNotFound)) + }) + }) + + Describe("GetWorkspaces", func() { + var ( + ns1 = "ns1" + ns2 = "ns2" + wsKind1 = &kubefloworgv1beta1.WorkspaceKind{ObjectMeta: metav1.ObjectMeta{Name: "kind1"}} + wsKind2 = &kubefloworgv1beta1.WorkspaceKind{ObjectMeta: metav1.ObjectMeta{Name: "kind2"}} + ws1 = &kubefloworgv1beta1.Workspace{ObjectMeta: metav1.ObjectMeta{Name: "ws1", Namespace: ns1}, Spec: kubefloworgv1beta1.WorkspaceSpec{Kind: "kind1"}} + ws2 = &kubefloworgv1beta1.Workspace{ObjectMeta: metav1.ObjectMeta{Name: "ws2", Namespace: ns1}, Spec: kubefloworgv1beta1.WorkspaceSpec{Kind: "kind2"}} + ws3 = &kubefloworgv1beta1.Workspace{ObjectMeta: metav1.ObjectMeta{Name: "ws3", Namespace: ns2}, Spec: kubefloworgv1beta1.WorkspaceSpec{Kind: "kind1"}} + ) + + It("gets all workspaces in a namespace", func() { + r := newTestWorkspaceRepository(ws1, ws2, ws3, wsKind1, wsKind2) + wss, err := r.GetWorkspaces(ctx, ns1) + Expect(err).NotTo(HaveOccurred()) + Expect(wss).To(HaveLen(2)) + Expect(wss).To(ContainElements( + workspaces.NewWorkspaceModelFromWorkspace(ws1, wsKind1), + workspaces.NewWorkspaceModelFromWorkspace(ws2, wsKind2), + )) + }) + + It("returns empty list when namespace has no workspaces", func() { + r := newTestWorkspaceRepository(ws1, ws2, ws3, wsKind1, wsKind2) + wss, err := r.GetWorkspaces(ctx, "nonexistent-ns") + Expect(err).NotTo(HaveOccurred()) + Expect(wss).To(BeEmpty()) + }) + + It("returns all workspaces in the cluster", func() { + r := newTestWorkspaceRepository(ws1, ws2, ws3, wsKind1, wsKind2) + wss, err := r.GetAllWorkspaces(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(wss).To(HaveLen(3)) + Expect(wss).To(ContainElements( + workspaces.NewWorkspaceModelFromWorkspace(ws1, wsKind1), + workspaces.NewWorkspaceModelFromWorkspace(ws2, wsKind2), + workspaces.NewWorkspaceModelFromWorkspace(ws3, wsKind1), + )) + }) + }) + + Describe("CreateWorkspace", func() { + var ( + namespace = "create-ns" + workspaceName = "new-ws" + workspaceCreate = &workspaces.WorkspaceCreate{ + Name: workspaceName, + Kind: "new-kind", + PodTemplate: workspaces.PodTemplateMutate{ + PodMetadata: workspaces.PodMetadataMutate{ + Labels: map[string]string{"foo": "bar"}, + Annotations: map[string]string{}, + }, + Volumes: workspaces.PodVolumesMutate{ + Data: []workspaces.PodVolumeMount{}, + }, + }, + } + ) + + It("creates a workspace successfully", func() { + r := newTestWorkspaceRepository() + result, err := r.CreateWorkspace(ctx, workspaceCreate, namespace) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(workspaceCreate)) + + created := &kubefloworgv1beta1.Workspace{} + err = r.client.Get(ctx, types.NamespacedName{Name: workspaceName, Namespace: namespace}, created) + Expect(err).NotTo(HaveOccurred()) + Expect(created.Spec.Kind).To(Equal(workspaceCreate.Kind)) + Expect(created.Spec.PodTemplate.PodMetadata.Labels).To(Equal(workspaceCreate.PodTemplate.PodMetadata.Labels)) + }) + + It("returns error if workspace already exists", func() { + existing := &kubefloworgv1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{Name: workspaceName, Namespace: namespace}, + } + r := newTestWorkspaceRepository(existing) + result, err := r.CreateWorkspace(ctx, workspaceCreate, namespace) + Expect(err).To(Equal(ErrWorkspaceAlreadyExists)) + Expect(result).To(BeNil()) + }) + }) + + Describe("DeleteWorkspace", func() { + var ( + namespace = "delete-ns" + workspaceName = "deletable-ws" + testWorkspace = &kubefloworgv1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceName, + Namespace: namespace, + }, + } + ) + + It("successfully deletes an existing workspace", func() { + r := newTestWorkspaceRepository(testWorkspace) + err := r.DeleteWorkspace(ctx, namespace, workspaceName) + Expect(err).NotTo(HaveOccurred()) + + ws := &kubefloworgv1beta1.Workspace{} + err = r.client.Get(ctx, types.NamespacedName{Name: workspaceName, Namespace: namespace}, ws) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }) + + It("returns error when deleting non-existent workspace", func() { + r := newTestWorkspaceRepository() + err := r.DeleteWorkspace(ctx, namespace, "non-existent") + Expect(err).To(Equal(ErrWorkspaceNotFound)) + }) + }) +})