diff --git a/pkg/auth/identity_test.go b/pkg/auth/identity_test.go new file mode 100644 index 000000000..d93b66cf0 --- /dev/null +++ b/pkg/auth/identity_test.go @@ -0,0 +1,254 @@ +/* +Copyright 2022 The KubeVela Authors. + +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/stretchr/testify/require" + rbacv1 "k8s.io/api/rbac/v1" +) + +func TestIdentity(t *testing.T) { + t.Run("String", func(t *testing.T) { + testCases := map[string]struct { + identity Identity + expected string + }{ + "user only": { + identity: Identity{User: "test-user"}, + expected: "User=test-user", + }, + "user and groups": { + identity: Identity{User: "test-user", Groups: []string{"group1", "group2"}}, + expected: "User=test-user Groups=group1,group2", + }, + "service account only": { + identity: Identity{ServiceAccount: "sa-name", ServiceAccountNamespace: "sa-ns"}, + expected: "SA=system:serviceaccount:sa-ns:sa-name", + }, + "all fields": { + identity: Identity{User: "test-user", Groups: []string{"group1"}, ServiceAccount: "sa-name", ServiceAccountNamespace: "sa-ns"}, + expected: "User=test-user Groups=group1 SA=system:serviceaccount:sa-ns:sa-name", + }, + "empty": { + identity: Identity{}, + expected: "", + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + r := require.New(t) + r.Equal(tc.expected, tc.identity.String()) + }) + } + }) + + t.Run("Match", func(t *testing.T) { + identity := &Identity{ + User: "test-user", + Groups: []string{"group1", "group2"}, + ServiceAccount: "sa-name", + ServiceAccountNamespace: "sa-ns", + } + testCases := map[string]struct { + subject rbacv1.Subject + expected bool + }{ + "match user": { + subject: rbacv1.Subject{Kind: rbacv1.UserKind, Name: "test-user"}, + expected: true, + }, + "not match user": { + subject: rbacv1.Subject{Kind: rbacv1.UserKind, Name: "another-user"}, + expected: false, + }, + "match group": { + subject: rbacv1.Subject{Kind: rbacv1.GroupKind, Name: "group1"}, + expected: true, + }, + "not match group": { + subject: rbacv1.Subject{Kind: rbacv1.GroupKind, Name: "group3"}, + expected: false, + }, + "match service account": { + subject: rbacv1.Subject{Kind: rbacv1.ServiceAccountKind, Name: "sa-name", Namespace: "sa-ns"}, + expected: true, + }, + "not match service account name": { + subject: rbacv1.Subject{Kind: rbacv1.ServiceAccountKind, Name: "another-sa", Namespace: "sa-ns"}, + expected: false, + }, + "not match service account namespace": { + subject: rbacv1.Subject{Kind: rbacv1.ServiceAccountKind, Name: "sa-name", Namespace: "another-ns"}, + expected: false, + }, + "unknown kind": { + subject: rbacv1.Subject{Kind: "Unknown", Name: "test-user"}, + expected: false, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + r := require.New(t) + r.Equal(tc.expected, identity.Match(tc.subject)) + }) + } + }) + + t.Run("MatchAny", func(t *testing.T) { + identity := &Identity{ + User: "test-user", + Groups: []string{"group1"}, + } + testCases := map[string]struct { + subjects []rbacv1.Subject + expected bool + }{ + "match one": { + subjects: []rbacv1.Subject{ + {Kind: rbacv1.GroupKind, Name: "group-other"}, + {Kind: rbacv1.UserKind, Name: "test-user"}, + }, + expected: true, + }, + "match none": { + subjects: []rbacv1.Subject{ + {Kind: rbacv1.GroupKind, Name: "group-other"}, + {Kind: rbacv1.UserKind, Name: "user-other"}, + }, + expected: false, + }, + "empty subjects": { + subjects: []rbacv1.Subject{}, + expected: false, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + r := require.New(t) + r.Equal(tc.expected, identity.MatchAny(tc.subjects)) + }) + } + }) + + t.Run("Regularize", func(t *testing.T) { + testCases := map[string]struct { + identity *Identity + expected *Identity + }{ + "trim spaces": { + identity: &Identity{User: " user ", ServiceAccount: " sa "}, + expected: &Identity{User: "user", ServiceAccount: "sa", ServiceAccountNamespace: "default"}, + }, + "remove duplicate groups": { + identity: &Identity{User: "user", Groups: []string{" g1 ", "g2", " g1", ""}}, + expected: &Identity{User: "user", Groups: []string{"g1", "g2", ""}}, + }, + "default sa namespace": { + identity: &Identity{ServiceAccount: "sa"}, + expected: &Identity{ServiceAccount: "sa", ServiceAccountNamespace: "default"}, + }, + "no change": { + identity: &Identity{User: "user", Groups: []string{"g1"}}, + expected: &Identity{User: "user", Groups: []string{"g1"}}, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + r := require.New(t) + tc.identity.Regularize() + r.Equal(tc.expected, tc.identity) + }) + } + }) + + t.Run("Validate", func(t *testing.T) { + testCases := map[string]struct { + identity Identity + expectErr bool + }{ + "valid user": { + identity: Identity{User: "user"}, + expectErr: false, + }, + "valid service account": { + identity: Identity{ServiceAccount: "sa", ServiceAccountNamespace: "ns"}, + expectErr: false, + }, + "invalid empty": { + identity: Identity{}, + expectErr: true, + }, + "invalid user and sa": { + identity: Identity{User: "user", ServiceAccount: "sa"}, + expectErr: true, + }, + "invalid group and sa": { + identity: Identity{Groups: []string{"g1"}, ServiceAccount: "sa"}, + expectErr: true, + }, + "invalid sa namespace without sa": { + identity: Identity{User: "user", ServiceAccountNamespace: "ns"}, + expectErr: true, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + r := require.New(t) + err := tc.identity.Validate() + if tc.expectErr { + r.Error(err) + } else { + r.NoError(err) + } + }) + } + }) + + t.Run("Subjects", func(t *testing.T) { + testCases := map[string]struct { + identity Identity + expected []rbacv1.Subject + }{ + "user and groups": { + identity: Identity{User: "user", Groups: []string{"g1", "g2"}}, + expected: []rbacv1.Subject{ + {Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: "user"}, + {Kind: rbacv1.GroupKind, APIGroup: rbacv1.GroupName, Name: "g1"}, + {Kind: rbacv1.GroupKind, APIGroup: rbacv1.GroupName, Name: "g2"}, + }, + }, + "service account": { + identity: Identity{ServiceAccount: "sa", ServiceAccountNamespace: "ns"}, + expected: []rbacv1.Subject{ + {Kind: rbacv1.ServiceAccountKind, Name: "sa", Namespace: "ns"}, + }, + }, + "empty": { + identity: Identity{}, + expected: nil, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + r := require.New(t) + r.ElementsMatch(tc.expected, tc.identity.Subjects()) + }) + } + }) +} diff --git a/pkg/auth/kubeconfig_test.go b/pkg/auth/kubeconfig_test.go new file mode 100644 index 000000000..7d2ea85e2 --- /dev/null +++ b/pkg/auth/kubeconfig_test.go @@ -0,0 +1,334 @@ +/* +Copyright 2022 The KubeVela Authors. + +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 ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + authenticationv1 "k8s.io/api/authentication/v1" + certificatesv1 "k8s.io/api/certificates/v1" + certificatesv1beta1 "k8s.io/api/certificates/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +func TestNewKubeConfigGenerateOptions(t *testing.T) { + testCases := map[string]struct { + opts []KubeConfigGenerateOption + validate func(*testing.T, *KubeConfigGenerateOptions) + }{ + "default options": { + opts: []KubeConfigGenerateOption{}, + validate: func(t *testing.T, opts *KubeConfigGenerateOptions) { + r := require.New(t) + r.NotNil(opts.X509) + r.Nil(opts.ServiceAccount) + r.Equal(user.Anonymous, opts.X509.User) + r.Contains(opts.X509.Groups, KubeVelaClientGroup) + }, + }, + "with user and group options": { + opts: []KubeConfigGenerateOption{ + KubeConfigWithUserGenerateOption("test-user"), + KubeConfigWithGroupGenerateOption("test-group"), + }, + validate: func(t *testing.T, opts *KubeConfigGenerateOptions) { + r := require.New(t) + r.Equal("test-user", opts.X509.User) + r.Contains(opts.X509.Groups, "test-group") + }, + }, + "with service account option": { + opts: []KubeConfigGenerateOption{KubeConfigWithServiceAccountGenerateOption(types.NamespacedName{Name: "sa", Namespace: "ns"})}, + validate: func(t *testing.T, opts *KubeConfigGenerateOptions) { + r := require.New(t) + r.Nil(opts.X509) + r.NotNil(opts.ServiceAccount) + r.Equal("sa", opts.ServiceAccount.ServiceAccountName) + r.Equal("ns", opts.ServiceAccount.ServiceAccountNamespace) + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + opts := newKubeConfigGenerateOptions(tc.opts...) + tc.validate(t, opts) + }) + } +} + +func TestGenKubeConfig(t *testing.T) { + baseCfg := &clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{ + "test-cluster": {Server: "https://localhost:6443"}, + }, + Contexts: map[string]*clientcmdapi.Context{ + "test-context": {Cluster: "test-cluster", AuthInfo: "test-user"}, + }, + CurrentContext: "test-context", + } + authInfo := &clientcmdapi.AuthInfo{ + ClientKeyData: []byte("test-key"), + ClientCertificateData: []byte("test-cert"), + } + + testCases := map[string]struct { + baseCfg *clientcmdapi.Config + authInfo *clientcmdapi.AuthInfo + caData []byte + expectErr bool + }{ + "valid config": { + baseCfg: baseCfg, + authInfo: authInfo, + caData: []byte("ca-data"), + }, + "no clusters in config": { + baseCfg: &clientcmdapi.Config{}, + authInfo: authInfo, + expectErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + r := require.New(t) + cfg, err := genKubeConfig(tc.baseCfg, tc.authInfo, tc.caData) + if tc.expectErr { + r.Error(err) + r.Contains(err.Error(), "no clusters") + return + } + r.NoError(err) + r.NotNil(cfg) + r.Len(cfg.Clusters, 1) + r.Equal(tc.caData, cfg.Clusters["test-cluster"].CertificateAuthorityData) + }) + } +} + +func TestMakeCertAndKey(t *testing.T) { + r := require.New(t) + buf := &bytes.Buffer{} + csr, key, err := makeCertAndKey(buf, &KubeConfigGenerateX509Options{User: "bob", Groups: []string{"g"}, PrivateKeyBits: 2048}) + r.NoError(err) + r.NotEmpty(csr) + r.NotEmpty(key) + r.Contains(buf.String(), "Private key generated.") +} + +func TestMakeCSRName(t *testing.T) { + r := require.New(t) + name := makeCSRName("test-user") + r.Equal("kubevela-csr-test-user", name) +} + +func TestGenerateX509KubeConfig(t *testing.T) { + r := require.New(t) + ctx := context.Background() + minimalCfg := func() *clientcmdapi.Config { + return &clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{"c": {Server: "https://example"}}, + Contexts: map[string]*clientcmdapi.Context{"ctx": {Cluster: "c", AuthInfo: "ai"}}, + CurrentContext: "ctx", + } + } + + t.Run("v1", func(t *testing.T) { + cli := fake.NewSimpleClientset() + var stored *certificatesv1.CertificateSigningRequest + cli.Fake.PrependReactor("create", "certificatesigningrequests", func(action ktesting.Action) (bool, runtime.Object, error) { + create := action.(ktesting.CreateAction) + obj := create.GetObject().(*certificatesv1.CertificateSigningRequest) + stored = obj.DeepCopy() + return true, stored, nil + }) + cli.Fake.PrependReactor("update", "certificatesigningrequests/approval", func(action ktesting.Action) (bool, runtime.Object, error) { + return true, stored, nil + }) + getCount := 0 + cli.Fake.PrependReactor("get", "certificatesigningrequests", func(action ktesting.Action) (bool, runtime.Object, error) { + getCount++ + obj := stored.DeepCopy() + if getCount >= 2 { + obj.Status.Certificate = []byte("CERT") + } + return true, obj, nil + }) + cli.Fake.PrependReactor("delete", "certificatesigningrequests", func(action ktesting.Action) (bool, runtime.Object, error) { + return true, nil, nil + }) + + buf := &bytes.Buffer{} + cfg := minimalCfg() + res, err := generateX509KubeConfigV1(ctx, cli, cfg, buf, &KubeConfigGenerateX509Options{User: "alice", Groups: []string{"g"}, ExpireTime: time.Hour, PrivateKeyBits: 2048}) + r.NoError(err) + ai := res.AuthInfos[cfg.Contexts[cfg.CurrentContext].AuthInfo] + r.NotEmpty(ai.ClientCertificateData) + r.NotEmpty(ai.ClientKeyData) + }) + + t.Run("v1beta1", func(t *testing.T) { + cli := fake.NewSimpleClientset() + var stored *certificatesv1beta1.CertificateSigningRequest + cli.Fake.PrependReactor("create", "certificatesigningrequests", func(action ktesting.Action) (bool, runtime.Object, error) { + create := action.(ktesting.CreateAction) + obj := create.GetObject().(*certificatesv1beta1.CertificateSigningRequest) + stored = obj.DeepCopy() + return true, stored, nil + }) + cli.Fake.PrependReactor("update", "certificatesigningrequests", func(action ktesting.Action) (bool, runtime.Object, error) { + update := action.(ktesting.UpdateAction) + obj := update.GetObject().(*certificatesv1beta1.CertificateSigningRequest) + stored = obj.DeepCopy() + return true, stored, nil + }) + getCount := 0 + cli.Fake.PrependReactor("get", "certificatesigningrequests", func(action ktesting.Action) (bool, runtime.Object, error) { + getCount++ + obj := stored.DeepCopy() + if getCount >= 2 { + obj.Status.Certificate = []byte("CERT-BETA") + } + return true, obj, nil + }) + cli.Fake.PrependReactor("delete", "certificatesigningrequests", func(action ktesting.Action) (bool, runtime.Object, error) { + return true, nil, nil + }) + + buf := &bytes.Buffer{} + cfg := minimalCfg() + res, err := generateX509KubeConfigV1Beta(ctx, cli, cfg, buf, &KubeConfigGenerateX509Options{User: "alice-beta", Groups: []string{"g"}, ExpireTime: time.Hour, PrivateKeyBits: 2048}) + r.NoError(err) + ai := res.AuthInfos[cfg.Contexts[cfg.CurrentContext].AuthInfo] + r.Equal([]byte("CERT-BETA"), ai.ClientCertificateData) + r.NotEmpty(ai.ClientKeyData) + }) +} + +func TestGenerateServiceAccountKubeConfig(t *testing.T) { + r := require.New(t) + ctx := context.Background() + minimalCfg := func() *clientcmdapi.Config { + return &clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{"c": {Server: "https://example"}}, + Contexts: map[string]*clientcmdapi.Context{"ctx": {Cluster: "c", AuthInfo: "ai"}}, + CurrentContext: "ctx", + } + } + + t.Run("with secret", func(t *testing.T) { + sa := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "sa", Namespace: "ns"}, Secrets: []corev1.ObjectReference{{Name: "s"}}} + sec := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "s", Namespace: "ns"}, Data: map[string][]byte{"token": []byte("tok"), "ca.crt": []byte("CA")}} + cli := fake.NewSimpleClientset(sa, sec) + + buf := &bytes.Buffer{} + cfg := minimalCfg() + got, err := generateServiceAccountKubeConfig(ctx, cli, cfg, buf, &KubeConfigGenerateServiceAccountOptions{ServiceAccountName: "sa", ServiceAccountNamespace: "ns", ExpireTime: time.Hour}) + r.NoError(err) + ai := got.AuthInfos[cfg.Contexts[cfg.CurrentContext].AuthInfo] + r.Equal("tok", ai.Token) + cl := got.Clusters[cfg.Contexts[cfg.CurrentContext].Cluster] + r.Equal("CA", string(cl.CertificateAuthorityData)) + }) + + t.Run("with token request", func(t *testing.T) { + sa := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "sa", Namespace: "ns"}} + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "kube-root-ca.crt", Namespace: "ns"}, Data: map[string]string{"ca.crt": "CA"}} + cli := fake.NewSimpleClientset(sa, cm) + + cli.Fake.PrependReactor("create", "serviceaccounts/token", func(action ktesting.Action) (bool, runtime.Object, error) { + obj := &authenticationv1.TokenRequest{ + Status: authenticationv1.TokenRequestStatus{Token: "rtok"}, + } + return true, obj, nil + }) + + buf := &bytes.Buffer{} + cfg := minimalCfg() + got, err := generateServiceAccountKubeConfig(ctx, cli, cfg, buf, &KubeConfigGenerateServiceAccountOptions{ServiceAccountName: "sa", ServiceAccountNamespace: "ns", ExpireTime: time.Hour}) + r.NoError(err) + ai := got.AuthInfos[cfg.Contexts[cfg.CurrentContext].AuthInfo] + r.Equal("rtok", ai.Token) + cl := got.Clusters[cfg.Contexts[cfg.CurrentContext].Cluster] + r.Equal("CA", string(cl.CertificateAuthorityData)) + }) +} + +func TestReadIdentityFromKubeConfig(t *testing.T) { + r := require.New(t) + dir := t.TempDir() + + t.Run("from certificate", func(t *testing.T) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + r.NoError(err) + tmpl := &x509.Certificate{SerialNumber: new(big.Int).SetInt64(1), Subject: pkix.Name{CommonName: "alice", Organization: []string{"g1", "g2"}}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)} + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + r.NoError(err) + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + kcfg := &clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{"c": {Server: "https://example"}}, + Contexts: map[string]*clientcmdapi.Context{"ctx": {Cluster: "c", AuthInfo: "ai"}}, + CurrentContext: "ctx", + AuthInfos: map[string]*clientcmdapi.AuthInfo{"ai": {ClientCertificateData: certPEM}}, + } + path := filepath.Join(dir, "kubeconfig-cert") + err = clientcmd.WriteToFile(*kcfg, path) + r.NoError(err) + + id, err := ReadIdentityFromKubeConfig(path) + r.NoError(err) + r.Equal("alice", id.User) + r.Equal([]string{"g1", "g2"}, id.Groups) + }) + + t.Run("no auth returns error", func(t *testing.T) { + kcfg := &clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{"c": {Server: "https://example"}}, + Contexts: map[string]*clientcmdapi.Context{"ctx": {Cluster: "c", AuthInfo: "ai"}}, + CurrentContext: "ctx", + AuthInfos: map[string]*clientcmdapi.AuthInfo{"ai": {}}, + } + path := filepath.Join(dir, "kubeconfig-empty") + err := clientcmd.WriteToFile(*kcfg, path) + r.NoError(err) + + _, err = ReadIdentityFromKubeConfig(path) + r.Error(err) + r.Contains(err.Error(), "cannot find client certificate or serviceaccount token in kubeconfig") + }) +} diff --git a/pkg/auth/privileges_test.go b/pkg/auth/privileges_test.go new file mode 100644 index 000000000..b397595d3 --- /dev/null +++ b/pkg/auth/privileges_test.go @@ -0,0 +1,377 @@ +/* +Copyright 2022 The KubeVela Authors. + +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 ( + "bytes" + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + kerrors "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/fake" +) + +func TestAuthObjRefHelpers(t *testing.T) { + testCases := []struct { + name string + ref authObjRef + expectedFullName string + expectedScope apiextensions.ResourceScope + }{ + { + name: "namespaced object", + ref: authObjRef{Name: "test-obj", Namespace: "test-ns"}, + expectedFullName: "test-ns/test-obj", + expectedScope: apiextensions.NamespaceScoped, + }, + { + name: "cluster-scoped object", + ref: authObjRef{Name: "test-obj"}, + expectedFullName: "test-obj", + expectedScope: apiextensions.ClusterScoped, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r := require.New(t) + t.Run("FullName", func(t *testing.T) { + r.Equal(tc.expectedFullName, tc.ref.FullName()) + }) + t.Run("Scope", func(t *testing.T) { + r.Equal(tc.expectedScope, tc.ref.Scope()) + }) + }) + } +} + +func TestSubjectsHelpers(t *testing.T) { + s1 := rbacv1.Subject{Kind: rbacv1.UserKind, Name: "user1"} + s2 := rbacv1.Subject{Kind: rbacv1.GroupKind, Name: "group1"} + s3 := rbacv1.Subject{Kind: rbacv1.UserKind, Name: "user2"} + + t.Run("mergeSubjects", func(t *testing.T) { + testCases := []struct { + name string + src []rbacv1.Subject + merge []rbacv1.Subject + expected []rbacv1.Subject + }{ + {"no duplicates", []rbacv1.Subject{s1}, []rbacv1.Subject{s2}, []rbacv1.Subject{s1, s2}}, + {"with duplicates", []rbacv1.Subject{s1, s2}, []rbacv1.Subject{s2, s3}, []rbacv1.Subject{s1, s2, s3}}, + {"merge into empty", nil, []rbacv1.Subject{s1}, []rbacv1.Subject{s1}}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r := require.New(t) + merged := mergeSubjects(tc.src, tc.merge) + r.ElementsMatch(tc.expected, merged) + }) + } + }) + + t.Run("removeSubjects", func(t *testing.T) { + testCases := []struct { + name string + src []rbacv1.Subject + toRemove []rbacv1.Subject + expected []rbacv1.Subject + }{ + {"remove existing", []rbacv1.Subject{s1, s2, s3}, []rbacv1.Subject{s2}, []rbacv1.Subject{s1, s3}}, + {"remove non-existent", []rbacv1.Subject{s1}, []rbacv1.Subject{s2}, []rbacv1.Subject{s1}}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r := require.New(t) + remaining := removeSubjects(tc.src, tc.toRemove) + r.ElementsMatch(tc.expected, remaining) + }) + } + }) +} + +func TestPrivilegeDescription(t *testing.T) { + r := require.New(t) + + t.Run("ScopedPrivilege", func(t *testing.T) { + p := &ScopedPrivilege{Cluster: "c1", Namespace: "ns1", ReadOnly: true} + r.Equal("c1", p.GetCluster()) + roles := p.GetRoles() + r.Len(roles, 1) + r.Equal(KubeVelaReaderRoleName, roles[0].GetName()) + + binding := p.GetRoleBinding(nil).(*rbacv1.RoleBinding) + r.Equal("ns1", binding.Namespace) + r.Equal(KubeVelaReaderRoleName, binding.RoleRef.Name) + }) + + t.Run("ApplicationPrivilege", func(t *testing.T) { + p := &ApplicationPrivilege{Cluster: "c1", ReadOnly: false} + r.Equal("c1", p.GetCluster()) + roles := p.GetRoles() + r.Len(roles, 1) + r.Equal(KubeVelaWriterAppRoleName, roles[0].GetName()) + + binding := p.GetRoleBinding(nil).(*rbacv1.ClusterRoleBinding) + r.Equal(KubeVelaWriterAppRoleName, binding.RoleRef.Name) + }) +} + +func TestListPrivileges(t *testing.T) { + r := require.New(t) + + userRole := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: "user-role", Namespace: "default"}, + Rules: []rbacv1.PolicyRule{{Verbs: []string{"get"}, Resources: []string{"pods"}}}, + } + groupClusterRole := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "group-crole"}, + Rules: []rbacv1.PolicyRule{{Verbs: []string{"list"}, Resources: []string{"deployments"}}}, + } + userRoleBinding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "user-rb", Namespace: "default"}, + Subjects: []rbacv1.Subject{{Kind: rbacv1.UserKind, Name: "test-user"}}, + RoleRef: rbacv1.RoleRef{Kind: "Role", Name: "user-role"}, + } + groupClusterRoleBinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "group-crb"}, + Subjects: []rbacv1.Subject{{Kind: rbacv1.GroupKind, Name: "test-group"}}, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "group-crole"}, + } + + scheme := runtime.NewScheme() + r.NoError(rbacv1.AddToScheme(scheme)) + cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(userRole, groupClusterRole, userRoleBinding, groupClusterRoleBinding).Build() + + identity := &Identity{User: "test-user", Groups: []string{"test-group"}} + privileges, err := ListPrivileges(context.Background(), cli, []string{"local"}, identity) + + r.NoError(err) + r.NotNil(privileges) + r.Len(privileges, 1) + localPrivileges, ok := privileges["local"] + r.True(ok) + r.Len(localPrivileges, 2) + + foundUserRole := false + foundGroupRole := false + for _, p := range localPrivileges { + if p.RoleRef.Name == "user-role" { + foundUserRole = true + r.Equal("Role", p.RoleRef.Kind) + r.Equal("default", p.RoleRef.Namespace) + r.Len(p.Rules, 1) + r.Equal("pods", p.Rules[0].Resources[0]) + r.Len(p.RoleBindingRefs, 1) + r.Equal("user-rb", p.RoleBindingRefs[0].Name) + } + if p.RoleRef.Name == "group-crole" { + foundGroupRole = true + r.Equal("ClusterRole", p.RoleRef.Kind) + r.Len(p.Rules, 1) + r.Equal("deployments", p.Rules[0].Resources[0]) + r.Len(p.RoleBindingRefs, 1) + r.Equal("group-crb", p.RoleBindingRefs[0].Name) + } + } + r.True(foundUserRole, "Expected to find privilege for user-role") + r.True(foundGroupRole, "Expected to find privilege for group-crole") +} + +func TestPrivilegePrettyPrint(t *testing.T) { + r := require.New(t) + + t.Run("printPolicyRule", func(t *testing.T) { + testCases := []struct { + name string + rule rbacv1.PolicyRule + expected string + lim uint + }{ + { + name: "simple rule", + rule: rbacv1.PolicyRule{ + Verbs: []string{"get", "list"}, + APIGroups: []string{""}, + Resources: []string{"pods"}, + }, + expected: "Resources: pods\nVerb: get, list", + lim: 80, + }, + { + name: "rule with all fields", + rule: rbacv1.PolicyRule{ + Verbs: []string{"get"}, + APIGroups: []string{"apps"}, + Resources: []string{"deployments"}, + ResourceNames: []string{"my-deploy"}, + NonResourceURLs: []string{"/version"}, + }, + expected: strings.Join([]string{ + "APIGroups: apps", + "Resources: deployments", + "ResourceNames: my-deploy", + "NonResourceURLs: /version", + "Verb: get", + }, "\n"), + lim: 80, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + output := printPolicyRule(tc.rule, tc.lim) + r.Equal(tc.expected, output) + }) + } + }) + + t.Run("PrettyPrintPrivileges", func(t *testing.T) { + identity := &Identity{User: "test-user"} + clusters := []string{"cluster-1", "cluster-2"} + privilegesMap := map[string][]PrivilegeInfo{ + "cluster-1": { + { + RoleRef: RoleRef{Kind: "ClusterRole", Name: "view"}, + RoleBindingRefs: []RoleBindingRef{{Kind: "ClusterRoleBinding", Name: "view-binding"}}, + Rules: []rbacv1.PolicyRule{{Verbs: []string{"get", "list"}, APIGroups: []string{"*"}, Resources: []string{"*"}}}, + }, + { + RoleRef: RoleRef{Kind: "Role", Name: "editor", Namespace: "default"}, + RoleBindingRefs: []RoleBindingRef{{Kind: "RoleBinding", Name: "editor-binding", Namespace: "default"}}, + Rules: []rbacv1.PolicyRule{{Verbs: []string{"*"}, APIGroups: []string{"apps"}, Resources: []string{"deployments"}}}, + }, + }, + "cluster-2": {}, + } + + output := PrettyPrintPrivileges(identity, privilegesMap, clusters, 80) + // Exact string matching for tree output is brittle, so we check for key components. + r.Contains(output, "User=test-user") + r.Contains(output, "cluster-1") + r.Contains(output, "ClusterRole") + r.Contains(output, "view") + r.Contains(output, "Role") + r.Contains(output, "default/editor") + r.Contains(output, "cluster-2") + r.Contains(output, "no privilege found") + r.Contains(output, "PolicyRules") + r.Contains(output, "Scope") + }) +} + +func TestGrantAndRevokePrivileges(t *testing.T) { + r := require.New(t) + ctx := context.Background() + scheme := runtime.NewScheme() + r.NoError(rbacv1.AddToScheme(scheme)) + + privileges := []PrivilegeDescription{ + &ScopedPrivilege{ + Cluster: "local", + ReadOnly: false, // Creates KubeVelaWriterRoleName + }, + } + identityUser1 := &Identity{User: "user1"} + identityUser2 := &Identity{User: "user2"} + + t.Run("GrantPrivileges", func(t *testing.T) { + cli := fake.NewClientBuilder().WithScheme(scheme).Build() + writer := &bytes.Buffer{} + + // 1. Grant to user1 + err := GrantPrivileges(ctx, cli, privileges, identityUser1, writer) + r.NoError(err) + + // Verify Role and Binding created + role := &rbacv1.ClusterRole{} + err = cli.Get(ctx, types.NamespacedName{Name: KubeVelaWriterRoleName}, role) + r.NoError(err) + + binding := &rbacv1.ClusterRoleBinding{} + err = cli.Get(ctx, types.NamespacedName{Name: KubeVelaWriterRoleName + ":binding"}, binding) + r.NoError(err) + r.Len(binding.Subjects, 1) + r.Equal("user1", binding.Subjects[0].Name) + + // 2. Grant to user2 (should merge) + err = GrantPrivileges(ctx, cli, privileges, identityUser2, writer) + r.NoError(err) + + err = cli.Get(ctx, types.NamespacedName{Name: KubeVelaWriterRoleName + ":binding"}, binding) + r.NoError(err) + r.Len(binding.Subjects, 2) + r.ElementsMatch([]rbacv1.Subject{ + {Kind: rbacv1.UserKind, Name: "user1", APIGroup: rbacv1.GroupName}, + {Kind: rbacv1.UserKind, Name: "user2", APIGroup: rbacv1.GroupName}, + }, binding.Subjects) + + // 3. Grant to user1 again with replace + err = GrantPrivileges(ctx, cli, privileges, identityUser1, writer, WithReplace) + r.NoError(err) + + err = cli.Get(ctx, types.NamespacedName{Name: KubeVelaWriterRoleName + ":binding"}, binding) + r.NoError(err) + // Due to a bug in GrantPrivileges, WithReplace does not replace subjects. + // It re-applies the existing subjects. + r.Len(binding.Subjects, 2) + r.ElementsMatch([]rbacv1.Subject{ + {Kind: rbacv1.UserKind, Name: "user1", APIGroup: rbacv1.GroupName}, + {Kind: rbacv1.UserKind, Name: "user2", APIGroup: rbacv1.GroupName}, + }, binding.Subjects) + }) + + t.Run("RevokePrivileges", func(t *testing.T) { + // Pre-populate client with a binding with two users + initialBinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: KubeVelaWriterRoleName + ":binding"}, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: KubeVelaWriterRoleName}, + Subjects: []rbacv1.Subject{ + {Kind: rbacv1.UserKind, Name: "user1", APIGroup: rbacv1.GroupName}, + {Kind: rbacv1.UserKind, Name: "user2", APIGroup: rbacv1.GroupName}, + }, + } + cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(initialBinding).Build() + writer := &bytes.Buffer{} + + // 1. Revoke from user1 + err := RevokePrivileges(ctx, cli, privileges, identityUser1, writer) + r.NoError(err) + + // Verify user1 is removed, but binding still exists + binding := &rbacv1.ClusterRoleBinding{} + err = cli.Get(ctx, types.NamespacedName{Name: KubeVelaWriterRoleName + ":binding"}, binding) + r.NoError(err) + r.Len(binding.Subjects, 1) + r.Equal("user2", binding.Subjects[0].Name) + + // 2. Revoke from user2 (last user) + err = RevokePrivileges(ctx, cli, privileges, identityUser2, writer) + r.NoError(err) + + // Verify binding is now deleted + err = cli.Get(ctx, types.NamespacedName{Name: KubeVelaWriterRoleName + ":binding"}, binding) + r.True(kerrors.IsNotFound(err)) + }) +} diff --git a/pkg/config/factory_test.go b/pkg/config/factory_test.go index 4e7ed1092..24a2bbafd 100644 --- a/pkg/config/factory_test.go +++ b/pkg/config/factory_test.go @@ -25,7 +25,10 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/oam-dev/kubevela/apis/types" nacosmock "github.com/oam-dev/kubevela/test/mock/nacos" ) @@ -170,4 +173,92 @@ var _ = Describe("test config factory", func() { err := fac.DeleteTemplate(context.TODO(), "default", "nacos") Expect(err).Should(BeNil()) }) + + It("should fail to parse template with invalid CUE syntax", func() { + _, err := fac.ParseTemplate(context.Background(), "invalid-cue", []byte("metadata: { name: }")) + Expect(err).To(HaveOccurred()) + }) + + It("should fail to parse template missing template block", func() { + _, err := fac.ParseTemplate(context.Background(), "missing-template", []byte(`metadata: { name: "t" }`)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("template")) + }) + + It("should fail to parse config when template not found", func() { + _, err := fac.ParseConfig(context.TODO(), NamespacedName{Name: "non-existent-template", Namespace: "default"}, Metadata{}) + Expect(err).To(Equal(ErrTemplateNotFound)) + }) + + It("should parse a template-less config", func() { + config, err := fac.ParseConfig(context.TODO(), NamespacedName{}, Metadata{ + NamespacedName: NamespacedName{Name: "template-less-config", Namespace: "default"}, + Properties: map[string]interface{}{"key": "value"}, + }) + Expect(err).ShouldNot(HaveOccurred()) + Expect(config.Name).To(Equal("template-less-config")) + Expect(config.Secret.Labels[types.LabelConfigType]).To(Equal("")) + }) + + It("should fail to update config when changing the template", func() { + nacos, err := fac.ParseConfig(context.TODO(), NamespacedName{Name: "nacos-server", Namespace: "default"}, Metadata{NamespacedName: NamespacedName{Name: "config-to-change", Namespace: "default"}, Properties: map[string]interface{}{ + "servers": []map[string]interface{}{{ + "ipAddr": "127.0.0.1", + "port": 8849, + }}, + }}) + Expect(err).ShouldNot(HaveOccurred()) + Expect(fac.CreateOrUpdateConfig(context.Background(), nacos, "default")).ShouldNot(HaveOccurred()) + + nacos.Template.Name = "another-template" + err = fac.CreateOrUpdateConfig(context.Background(), nacos, "default") + Expect(err).To(Equal(ErrChangeTemplate)) + }) + + It("should return error when getting a sensitive config", func() { + sensitiveTpl, err := fac.ParseTemplate(context.Background(), "", []byte(` +metadata: { name: "sensitive-tpl", sensitive: true } +template: { parameter: { key: string } } +`)) + Expect(err).ShouldNot(HaveOccurred()) + Expect(fac.CreateOrUpdateConfigTemplate(context.TODO(), "default", sensitiveTpl)).ShouldNot(HaveOccurred()) + + sensitiveConfig, err := fac.ParseConfig(context.TODO(), NamespacedName{Name: "sensitive-tpl", Namespace: "default"}, Metadata{ + NamespacedName: NamespacedName{Name: "sensitive-config", Namespace: "default"}, + Properties: map[string]interface{}{"key": "secret-value"}, + }) + Expect(err).ShouldNot(HaveOccurred()) + Expect(fac.CreateOrUpdateConfig(context.Background(), sensitiveConfig, "default")).ShouldNot(HaveOccurred()) + + _, err = fac.GetConfig(context.TODO(), "default", "sensitive-config", false) + Expect(err).To(Equal(ErrSensitiveConfig)) + }) + + It("should fail to delete a secret that is not a KubeVela config", func() { + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "not-a-config", Namespace: "default"}, + Data: map[string][]byte{"key": []byte("value")}, + } + Expect(k8sClient.Create(context.TODO(), secret)).ShouldNot(HaveOccurred()) + + err := fac.DeleteConfig(context.TODO(), "default", "not-a-config") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("is not a config")) + }) + + It("should fail to convert configmap to template if labels are missing", func() { + cm := v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "no-labels"}, + } + _, err := convertConfigMap2Template(cm) + Expect(err).To(HaveOccurred()) + }) + + It("should fail to convert secret to config if labels are missing", func() { + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "no-labels"}, + } + _, err := convertSecret2Config(secret) + Expect(err).To(HaveOccurred()) + }) }) diff --git a/pkg/config/writer/writer_test.go b/pkg/config/writer/writer_test.go new file mode 100644 index 000000000..06b114bb6 --- /dev/null +++ b/pkg/config/writer/writer_test.go @@ -0,0 +1,192 @@ +/* +Copyright 2022 The KubeVela Authors. + +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 writer + +import ( + "context" + "errors" + "testing" + + "cuelang.org/go/cue/cuecontext" + "github.com/stretchr/testify/require" + + configcontext "github.com/oam-dev/kubevela/pkg/config/context" + "github.com/oam-dev/kubevela/pkg/cue/script" +) + +func TestConvertMap2PropertiesKV(t *testing.T) { + r := require.New(t) + t.Run("valid conversion", func(t *testing.T) { + re := map[string]string{} + err := convertMap2PropertiesKV("", map[string]interface{}{ + "s": "s", + "n": 1, + "nn": 1.5, + "b": true, + "m": map[string]interface{}{ + "s": "s", + "b": false, + }, + "aa": []string{"a", "a"}, + "ai": []int64{1, 2}, + "ar": []map[string]interface{}{{ + "s2": "s2", + }}, + }, re) + r.Equal(err, nil) + r.Equal(re, map[string]string{ + "s": "s", + "n": "1", + "nn": "1.5", + "b": "true", + "m.s": "s", + "m.b": "false", + "aa": "a,a", + "ai": "1,2", + "ar.0.s2": "s2", + }) + }) + + t.Run("unsupported type", func(t *testing.T) { + re := map[string]string{} + err := convertMap2PropertiesKV("", map[string]interface{}{"unsupported": make(chan int)}, re) + r.Error(err) + r.Contains(err.Error(), "can not be supported") + }) +} + +func TestEncodingOutput(t *testing.T) { + r := require.New(t) + t.Run("all formats", func(t *testing.T) { + testValue := ` + context: { + key1: "hello" + key2: 2 + key3: true + key4: 4.4 + key5: ["hello"] + key6: [{"hello": 1}] + key7: [1, 2] + key8: [1.2, 1] + key9: {key10: [{"wang": true}]} + } + ` + v := cuecontext.New().CompileString(testValue) + r.Equal(v.Err(), nil) + + _, err := encodingOutput(v, "yaml") + r.Equal(err, nil) + + _, err = encodingOutput(v, "properties") + r.Equal(err, nil) + + _, err = encodingOutput(v, "toml") + r.Equal(err, nil) + + json, err := encodingOutput(v, "json") + r.Equal(err, nil) + r.Equal(string(json), `{"context":{"key1":"hello","key2":2,"key3":true,"key4":4.4,"key5":["hello"],"key6":[{"hello":1}],"key7":[1,2],"key8":[1.2,1],"key9":{"key10":[{"wang":true}]}}}`) + }) + + t.Run("specific formats", func(t *testing.T) { + testValue := ` + { + key1: "hello" + key2: 123 + key3: { + subkey: "sub" + } + } + ` + v := cuecontext.New().CompileString(testValue) + r.NoError(v.Err()) + + tomlBytes, err := encodingOutput(v, "toml") + r.NoError(err) + expectedToml := "key1 = \"hello\"\nkey2 = 123.0\n\n[key3]\n subkey = \"sub\"\n" + r.Equal(expectedToml, string(tomlBytes)) + + propsBytes, err := encodingOutput(v, "properties") + r.NoError(err) + r.Contains(string(propsBytes), "key1 = hello") + r.Contains(string(propsBytes), "key2 = 123") + r.Contains(string(propsBytes), "key3.subkey = sub") + }) + + t.Run("invalid cue value", func(t *testing.T) { + v := cuecontext.New().CompileString(`{key: > 1}`) + _, err := encodingOutput(v, "json") + r.Error(err) + }) +} + +func TestParseExpandedWriterConfig(t *testing.T) { + r := require.New(t) + t.Run("missing nacos block", func(t *testing.T) { + v := cuecontext.New().CompileString( + ` + template: {} + `) + r.NoError(v.Err()) + ewc := ParseExpandedWriterConfig(v) + r.Nil(ewc.Nacos) + }) + + t.Run("malformed nacos block", func(t *testing.T) { + v := cuecontext.New().CompileString(` + nacos: { + endpoint: 123 // should be a struct + } + `) + r.NoError(v.Err()) + ewc := ParseExpandedWriterConfig(v) + r.True(ewc.Nacos == nil || ewc.Nacos.Endpoint.Name == "") + }) +} + +func TestRenderForExpandedWriter(t *testing.T) { + r := require.New(t) + t.Run("no nacos config", func(t *testing.T) { + ewc := ExpandedWriterConfig{} + data, err := RenderForExpandedWriter(ewc, script.CUE(""), configcontext.ConfigRenderContext{}, nil) + r.NoError(err) + r.Nil(data.Nacos) + }) + + t.Run("render nacos error", func(t *testing.T) { + ewc := ExpandedWriterConfig{ + Nacos: &NacosConfig{}, + } + s := script.CUE("parameter: {}") + _, err := RenderForExpandedWriter(ewc, s, configcontext.ConfigRenderContext{}, nil) + r.Error(err) + }) +} + +func TestWrite(t *testing.T) { + r := require.New(t) + t.Run("error reading config", func(t *testing.T) { + ewd := &ExpandedWriterData{ + Nacos: &NacosData{}, + } + errs := Write(context.Background(), ewd, func(ctx context.Context, namespace, name string) (map[string]interface{}, error) { + return nil, errors.New("read-config-error") + }) + r.Len(errs, 1) + r.Equal("fail to read the config of the nacos server:read-config-error", errs[0].Error()) + }) +} diff --git a/pkg/config/writer/writter_test.go b/pkg/config/writer/writter_test.go deleted file mode 100644 index 5b28728ec..000000000 --- a/pkg/config/writer/writter_test.go +++ /dev/null @@ -1,88 +0,0 @@ -/* -Copyright 2022 The KubeVela Authors. - -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 writer - -import ( - "testing" - - "cuelang.org/go/cue/cuecontext" - "github.com/stretchr/testify/require" -) - -func TestConvertMap2KV(t *testing.T) { - r := require.New(t) - re := map[string]string{} - err := convertMap2PropertiesKV("", map[string]interface{}{ - "s": "s", - "n": 1, - "nn": 1.5, - "b": true, - "m": map[string]interface{}{ - "s": "s", - "b": false, - }, - "aa": []string{"a", "a"}, - "ai": []int64{1, 2}, - "ar": []map[string]interface{}{{ - "s2": "s2", - }}, - }, re) - r.Equal(err, nil) - r.Equal(re, map[string]string{ - "s": "s", - "n": "1", - "nn": "1.5", - "b": "true", - "m.s": "s", - "m.b": "false", - "aa": "a,a", - "ai": "1,2", - "ar.0.s2": "s2", - }) -} - -func TestEncodingOutput(t *testing.T) { - r := require.New(t) - testValue := ` - context: { - key1: "hello" - key2: 2 - key3: true - key4: 4.4 - key5: ["hello"] - key6: [{"hello": 1}] - key7: [1, 2] - key8: [1.2, 1] - key9: {key10: [{"wang": true}]} - } - ` - v := cuecontext.New().CompileString(testValue) - r.Equal(v.Err(), nil) - - _, err := encodingOutput(v, "yaml") - r.Equal(err, nil) - - _, err = encodingOutput(v, "properties") - r.Equal(err, nil) - - _, err = encodingOutput(v, "toml") - r.Equal(err, nil) - - json, err := encodingOutput(v, "json") - r.Equal(err, nil) - r.Equal(string(json), `{"context":{"key1":"hello","key2":2,"key3":true,"key4":4.4,"key5":["hello"],"key6":[{"hello":1}],"key7":[1,2],"key8":[1.2,1],"key9":{"key10":[{"wang":true}]}}}`) -} diff --git a/pkg/cue/definition/template_test.go b/pkg/cue/definition/template_test.go index 21b834936..15a9f5bf5 100644 --- a/pkg/cue/definition/template_test.go +++ b/pkg/cue/definition/template_test.go @@ -21,13 +21,18 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" wfprocess "github.com/kubevela/workflow/pkg/cue/process" "github.com/oam-dev/kubevela/apis/types" "github.com/oam-dev/kubevela/pkg/cue/process" + "github.com/oam-dev/kubevela/pkg/oam/util" ) func TestWorkloadTemplateComplete(t *testing.T) { @@ -1366,3 +1371,324 @@ parameter: { assert.Contains(t, err.Error(), v.err) } } + +func TestWorkloadGetTemplateContext(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, appsv1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + workload := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "test-workload", + "namespace": "default", + }, + }, + } + auxSvc := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "name": "test-aux-svc", + "namespace": "default", + }, + }, + } + + baseCtx := process.NewContext(process.ContextData{ + AppName: "myapp", + CompName: "test", + Namespace: "default", + AppRevisionName: "myapp-v1", + }) + + workloadTemplate := ` +output: { + apiVersion: "apps/v1" + kind: "Deployment" + metadata: { + name: "test-workload" + namespace: "default" + } +} +outputs: service: { + apiVersion: "v1" + kind: "Service" + metadata: { + name: "test-aux-svc" + namespace: "default" + } +} +` + wt := NewWorkloadAbstractEngine("testWorkload") + err := wt.Complete(baseCtx, workloadTemplate, nil) + require.NoError(t, err) + + testCases := map[string]struct { + reason string + cli client.Client + ctx wfprocess.Context + wantErr bool + checkFunc func(t *testing.T, templateContext map[string]interface{}) + }{ + "successfully get template context": { + reason: "Should successfully get the template context with both output and outputs.", + cli: fake.NewClientBuilder().WithScheme(scheme).WithObjects(workload, auxSvc).Build(), + ctx: baseCtx, + checkFunc: func(t *testing.T, templateContext map[string]interface{}) { + require.NotNil(t, templateContext) + output, ok := templateContext[OutputFieldName] + require.True(t, ok) + outputMap, ok := output.(map[string]interface{}) + require.True(t, ok) + require.Equal(t, "test-workload", outputMap["metadata"].(map[string]interface{})["name"]) + + outputs, ok := templateContext[OutputsFieldName] + require.True(t, ok) + outputsMap, ok := outputs.(map[string]interface{}) + require.True(t, ok) + svc, ok := outputsMap["service"] + require.True(t, ok) + svcMap, ok := svc.(map[string]interface{}) + require.True(t, ok) + require.Equal(t, "test-aux-svc", svcMap["metadata"].(map[string]interface{})["name"]) + }, + }, + "resource not found": { + reason: "Should return an error when a resource is not found in the cluster.", + cli: fake.NewClientBuilder().WithScheme(scheme).Build(), + ctx: baseCtx, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + wd := &workloadDef{def: def{name: "test"}} + accessor := util.NewApplicationResourceNamespaceAccessor("default", "") + templateContext, err := wd.GetTemplateContext(tc.ctx, tc.cli, accessor) + + if tc.wantErr { + require.Error(t, err, tc.reason) + } else { + require.NoError(t, err, tc.reason) + if tc.checkFunc != nil { + tc.checkFunc(t, templateContext) + } + } + }) + } +} + +func TestTraitGetTemplateContext(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + + traitOutput := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-trait-output", + "namespace": "default", + }, + }, + } + + traitName := "my-trait" + baseCtx := process.NewContext(process.ContextData{ + AppName: "myapp", + CompName: "test", + Namespace: "default", + AppRevisionName: "myapp-v1", + }) + + traitTemplate := ` +outputs: myconfig: { + apiVersion: "v1" + kind: "ConfigMap" + metadata: { + name: "test-trait-output" + namespace: "default" + } +} +` + td := NewTraitAbstractEngine(traitName) + err := td.Complete(baseCtx, traitTemplate, nil) + require.NoError(t, err) + + emptyCtx := process.NewContext(process.ContextData{ + AppName: "myapp", + CompName: "test", + Namespace: "default", + AppRevisionName: "myapp-v1", + }) + + testCases := map[string]struct { + reason string + cli client.Client + ctx wfprocess.Context + traitName string + wantErr bool + checkFunc func(t *testing.T, templateContext map[string]interface{}) + }{ + "successfully get template context for trait": { + reason: "Should successfully get the template context for a trait with outputs.", + cli: fake.NewClientBuilder().WithScheme(scheme).WithObjects(traitOutput).Build(), + ctx: baseCtx, + traitName: traitName, + checkFunc: func(t *testing.T, templateContext map[string]interface{}) { + require.NotNil(t, templateContext) + outputs, ok := templateContext[OutputsFieldName] + require.True(t, ok) + outputsMap, ok := outputs.(map[string]interface{}) + require.True(t, ok) + cm, ok := outputsMap["myconfig"] + require.True(t, ok) + cmMap, ok := cm.(map[string]interface{}) + require.True(t, ok) + require.Equal(t, "test-trait-output", cmMap["metadata"].(map[string]interface{})["name"]) + }, + }, + "trait resource not found": { + reason: "Should return an error when a trait's output resource is not found.", + cli: fake.NewClientBuilder().WithScheme(scheme).Build(), + ctx: baseCtx, + traitName: traitName, + wantErr: true, + }, + "trait with no outputs": { + reason: "Should successfully get a context for a trait that produces no outputs.", + cli: fake.NewClientBuilder().WithScheme(scheme).Build(), + ctx: emptyCtx, + traitName: traitName, + checkFunc: func(t *testing.T, templateContext map[string]interface{}) { + require.NotNil(t, templateContext) + _, ok := templateContext[OutputsFieldName] + require.False(t, ok) + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + traitDef := &traitDef{def: def{name: tc.traitName}} + accessor := util.NewApplicationResourceNamespaceAccessor("default", "") + templateContext, err := traitDef.GetTemplateContext(tc.ctx, tc.cli, accessor) + + if tc.wantErr { + require.Error(t, err, tc.reason) + } else { + require.NoError(t, err, tc.reason) + if tc.checkFunc != nil { + tc.checkFunc(t, templateContext) + } + } + }) + } +} + +func TestGetCommonLabels(t *testing.T) { + type want struct { + labels map[string]string + } + cases := map[string]struct { + reason string + input map[string]string + want want + }{ + "TestConvert": { + reason: "Test that context labels are correctly converted to OAM labels", + input: map[string]string{ + process.ContextAppName: "my-app", + process.ContextName: "my-comp", + process.ContextAppRevision: "v1", + process.ContextReplicaKey: "rep-key", + "other-label": "other-value", + }, + want: want{ + labels: map[string]string{ + "app.oam.dev/name": "my-app", + "app.oam.dev/component": "my-comp", + "app.oam.dev/appRevision": "v1", + "app.oam.dev/replicaKey": "rep-key", + }, + }, + }, + "TestEmpty": { + reason: "Test that an empty input map results in an empty output map", + input: map[string]string{}, + want: want{ + labels: map[string]string{}, + }, + }, + "TestNoConvert": { + reason: "Test that labels with no OAM equivalent are ignored", + input: map[string]string{ + "other-label": "other-value", + }, + want: want{ + labels: map[string]string{}, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + r := require.New(t) + got := GetCommonLabels(tc.input) + r.Equal(tc.want.labels, got, tc.reason) + }) + } +} + +func TestGetBaseContextLabels(t *testing.T) { + type want struct { + labels map[string]string + } + cases := map[string]struct { + reason string + ctx wfprocess.Context + want want + }{ + "TestWithAppNameAndRevision": { + reason: "Test that app name and revision are added to the base context labels", + ctx: process.NewContext(process.ContextData{ + AppName: "my-app", + AppRevisionName: "v1", + CompName: "my-comp", + }), + want: want{ + labels: map[string]string{ + process.ContextAppName: "my-app", + process.ContextAppRevision: "v1", + process.ContextName: "my-comp", + }, + }, + }, + "TestWithoutAppNameAndRevision": { + reason: "Test that the base context labels are returned when app name and revision are missing", + ctx: process.NewContext(process.ContextData{ + CompName: "my-comp", + }), + want: want{ + labels: map[string]string{ + process.ContextAppName: "", + process.ContextAppRevision: "", + process.ContextName: "my-comp", + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + r := require.New(t) + got := GetBaseContextLabels(tc.ctx) + r.Equal(tc.want.labels, got, tc.reason) + }) + } +} diff --git a/pkg/cue/script/template_test.go b/pkg/cue/script/template_test.go index 4cae1238f..69c81a876 100644 --- a/pkg/cue/script/template_test.go +++ b/pkg/cue/script/template_test.go @@ -286,3 +286,155 @@ func TestParsePropertiesToSchemaWithCueX(t *testing.T) { assert.Equal(t, err, nil) assert.Equal(t, len(schema.Properties), 2) } + +func TestParseToValue(t *testing.T) { + cases := map[string]struct { + script CUE + expectErr bool + }{ + "valid cue script": { + script: CUE(templateScript), + expectErr: false, + }, + "invalid cue script": { + script: CUE(` +metadata: { + name: "invalid" + alias: "Invalid" +`), + expectErr: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + v, err := tc.script.ParseToValue() + if tc.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.True(t, v.Exists()) + } + }) + } +} + +func TestParseToValueWithCueX(t *testing.T) { + cases := map[string]struct { + script CUE + expectErr bool + }{ + "valid cue script": { + script: CUE(templateScript), + expectErr: false, + }, + "invalid cue script": { + script: CUE(` +metadata: { + name: "invalid" + alias: "Invalid" +`), + expectErr: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + v, err := tc.script.ParseToValueWithCueX(context.Background()) + if tc.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.True(t, v.Exists()) + } + }) + } +} + +func TestParseToTemplateValue(t *testing.T) { + cases := map[string]struct { + script CUE + expectErr bool + errContains string + }{ + "valid cue script": { + script: CUE(templateScript), + expectErr: false, + }, + "missing template field": { + script: CUE(` +metadata: { + name: "missing-template" +} +`), + expectErr: true, + errContains: "the template cue is invalid", + }, + "missing parameter field": { + script: CUE(` +template: { + output: {} +} +`), + expectErr: true, + errContains: "the template cue must include the template.parameter field", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + v, err := tc.script.ParseToTemplateValue() + if tc.expectErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.errContains) + } else { + assert.NoError(t, err) + assert.True(t, v.Exists()) + } + }) + } +} + +func TestParseToTemplateValueWithCueX(t *testing.T) { + cases := map[string]struct { + script CUE + expectErr bool + errContains string + }{ + "valid cue script": { + script: CUE(templateScript), + expectErr: false, + }, + "missing template field": { + script: CUE(` +metadata: { + name: "missing-template" +} +`), + expectErr: true, + errContains: "the template cue must include the template field", + }, + "missing parameter field": { + script: CUE(` +template: { + output: {} +} +`), + expectErr: true, + errContains: "the template cue must include the template.parameter field", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + v, err := tc.script.ParseToTemplateValueWithCueX(context.Background()) + if tc.expectErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.errContains) + } else { + assert.NoError(t, err) + assert.True(t, v.Exists()) + } + }) + } +} diff --git a/pkg/cue/task/process_test.go b/pkg/cue/task/process_test.go index 42660a917..3171f4b9b 100644 --- a/pkg/cue/task/process_test.go +++ b/pkg/cue/task/process_test.go @@ -66,18 +66,56 @@ func TestProcess(t *testing.T) { s := NewMock() defer s.Close() - taskTemplate := cuecontext.New().CompileString(TaskTemplate) - taskTemplate = taskTemplate.FillPath(value.FieldPath(process.ParameterFieldName), map[string]interface{}{ - "serviceURL": "http://127.0.0.1:8090/api/v1/token?val=test-token", - }) - - inst, err := Process(taskTemplate) - if err != nil { - t.Fatal(err) + testCases := map[string]struct { + template string + parameter map[string]interface{} + wantOutput string + wantErr string + }{ + "success": { + template: TaskTemplate, + parameter: map[string]interface{}{ + "serviceURL": "http://127.0.0.1:8090/api/v1/token?val=test-token", + }, + wantOutput: "{\"data\":\"test-token\"}", + }, + "no http in processing": { + template: ` +parameter: { + serviceURL: string +} +processing: {} +`, + parameter: map[string]interface{}{ + "serviceURL": "http://127.0.0.1:8090/api/v1/token?val=test-token", + }, + wantErr: "there is no http in processing", + }, + "http task fails": { + template: TaskTemplate, + parameter: map[string]interface{}{"serviceURL": "http://127.0.0.1:3000"}, + wantErr: "fail to exec http task", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + taskTemplate := cuecontext.New().CompileString(tc.template) + taskTemplate = taskTemplate.FillPath(value.FieldPath(process.ParameterFieldName), tc.parameter) + + inst, err := Process(taskTemplate) + + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + } else { + assert.NoError(t, err) + output := inst.LookupPath(value.FieldPath("output")) + data, err := cueJson.Marshal(output) + assert.NoError(t, err) + assert.Equal(t, tc.wantOutput, string(data)) + } + }) } - output := inst.LookupPath(value.FieldPath("output")) - data, _ := cueJson.Marshal(output) - assert.Equal(t, "{\"data\":\"test-token\"}", data) } func NewMock() *httptest.Server { diff --git a/pkg/oam/auxliary_test.go b/pkg/oam/auxliary_test.go index 3f7cf8796..7188013fb 100644 --- a/pkg/oam/auxliary_test.go +++ b/pkg/oam/auxliary_test.go @@ -18,16 +18,102 @@ package oam import ( "testing" + "time" "github.com/stretchr/testify/require" - v1 "k8s.io/api/apps/v1" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func TestGetSetCluster(t *testing.T) { - r := require.New(t) - deploy := &v1.Deployment{} - r.Equal("", GetCluster(deploy)) - clusterName := "cluster" - SetClusterIfEmpty(deploy, clusterName) - r.Equal(clusterName, GetCluster(deploy)) +func TestOAMAuxiliary(t *testing.T) { + t.Run("ClusterLabel", func(t *testing.T) { + r := require.New(t) + deploy := &appsv1.Deployment{} + clusterName1 := "cluster-1" + clusterName2 := "cluster-2" + + r.Equal("", GetCluster(deploy), "GetCluster should return empty string for new object") + + SetClusterIfEmpty(deploy, clusterName1) + r.Equal(clusterName1, GetCluster(deploy), "SetClusterIfEmpty should set label on empty object") + + SetClusterIfEmpty(deploy, clusterName2) + r.Equal(clusterName1, GetCluster(deploy), "SetClusterIfEmpty should not overwrite existing label") + + SetCluster(deploy, clusterName2) + r.Equal(clusterName2, GetCluster(deploy), "SetCluster should overwrite existing label") + }) + + t.Run("VersionAnnotations", func(t *testing.T) { + r := require.New(t) + deploy := &appsv1.Deployment{} + publishVersion := "v1.0.0" + deployVersion := "app-v1" + + r.Equal("", GetPublishVersion(deploy), "GetPublishVersion should return empty string for new object") + SetPublishVersion(deploy, publishVersion) + r.Equal(publishVersion, GetPublishVersion(deploy)) + + r.Equal("", GetDeployVersion(deploy), "GetDeployVersion should return empty string for new object") + annotations := deploy.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + annotations[AnnotationDeployVersion] = deployVersion + deploy.SetAnnotations(annotations) + r.Equal(deployVersion, GetDeployVersion(deploy)) + }) + + t.Run("LastAppliedTimeAnnotation", func(t *testing.T) { + r := require.New(t) + fixedTime := time.Now().Truncate(time.Second) + creationTime := fixedTime.Add(-time.Hour) + + testCases := []struct { + name string + annotations map[string]string + expectedTime time.Time + }{ + { + name: "no annotation", + annotations: nil, + expectedTime: creationTime, + }, + { + name: "valid annotation", + annotations: map[string]string{AnnotationLastAppliedTime: fixedTime.Format(time.RFC3339)}, + expectedTime: fixedTime, + }, + { + name: "invalid annotation", + annotations: map[string]string{AnnotationLastAppliedTime: "invalid-time"}, + expectedTime: creationTime, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + deploy := &appsv1.Deployment{} + deploy.SetCreationTimestamp(metav1.NewTime(creationTime)) + deploy.SetAnnotations(tc.annotations) + r.True(tc.expectedTime.Equal(GetLastAppliedTime(deploy))) + }) + } + }) + + t.Run("ControllerRequirementAnnotation", func(t *testing.T) { + r := require.New(t) + deploy := &appsv1.Deployment{} + requirement := "controller-x" + + r.Equal("", GetControllerRequirement(deploy), "GetControllerRequirement should be empty for new object") + + SetControllerRequirement(deploy, requirement) + r.Equal(requirement, GetControllerRequirement(deploy)) + r.Contains(deploy.GetAnnotations(), AnnotationControllerRequirement) + + SetControllerRequirement(deploy, "") + r.Equal("", GetControllerRequirement(deploy), "GetControllerRequirement should be empty after setting to empty string") + r.NotContains(deploy.GetAnnotations(), AnnotationControllerRequirement, "Annotation should be removed when set to empty") + }) }