diff --git a/charts/zitadel/acceptance/acceptance_test.go b/charts/zitadel/acceptance/acceptance_test.go new file mode 100644 index 0000000..df7a584 --- /dev/null +++ b/charts/zitadel/acceptance/acceptance_test.go @@ -0,0 +1,155 @@ +package acceptance_test + +import ( + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/gruntwork-io/terratest/modules/k8s" + "github.com/stretchr/testify/suite" + "github.com/zitadel/zitadel-charts/charts/zitadel/acceptance" +) + +func TestPostgresInsecure(t *testing.T) { + t.Parallel() + example := "1-postgres-insecure" + workDir, valuesFile, values := readConfig(t, example) + cfg := values.Zitadel.ConfigmapConfig + suite.Run(t, acceptance.Configure( + t, + newNamespaceIdentifier(example), + acceptance.Postgres.WithValues(filepath.Join(workDir, "postgres-values.yaml")), + []string{valuesFile}, + cfg.ExternalDomain, + cfg.ExternalPort, + cfg.ExternalSecure, + nil, + nil, + nil, + )) +} + +func TestPostgresSecure(t *testing.T) { + t.Parallel() + example := "2-postgres-secure" + workDir, valuesFile, values := readConfig(t, example) + cfg := values.Zitadel.ConfigmapConfig + suite.Run(t, acceptance.Configure( + t, + newNamespaceIdentifier(example), + acceptance.Postgres.WithValues(filepath.Join(workDir, "postgres-values.yaml")), + []string{valuesFile}, + cfg.ExternalDomain, + cfg.ExternalPort, + cfg.ExternalSecure, + func(cfg *acceptance.ConfigurationTest) { + k8s.KubectlApply(t, cfg.KubeOptions, filepath.Join(workDir, "certs-job.yaml")) + k8s.WaitUntilJobSucceed(t, cfg.KubeOptions, "create-certs", 120, 3*time.Second) + }, + nil, + nil, + )) +} + +func TestCockroachInsecure(t *testing.T) { + t.Parallel() + example := "3-cockroach-insecure" + workDir, valuesFile, values := readConfig(t, example) + cfg := values.Zitadel.ConfigmapConfig + suite.Run(t, acceptance.Configure( + t, + newNamespaceIdentifier(example), + acceptance.Cockroach.WithValues(filepath.Join(workDir, "cockroach-values.yaml")), + []string{valuesFile}, + cfg.ExternalDomain, + cfg.ExternalPort, + cfg.ExternalSecure, + nil, + nil, + nil, + )) +} + +func TestCockroachSecure(t *testing.T) { + t.Parallel() + example := "4-cockroach-secure" + workDir, valuesFile, values := readConfig(t, example) + cfg := values.Zitadel.ConfigmapConfig + suite.Run(t, acceptance.Configure( + t, + newNamespaceIdentifier(example), + acceptance.Cockroach.WithValues(filepath.Join(workDir, "cockroach-values.yaml")), + []string{valuesFile}, + cfg.ExternalDomain, + cfg.ExternalPort, + cfg.ExternalSecure, + nil, + func(cfg *acceptance.ConfigurationTest) { + k8s.KubectlApply(t, cfg.KubeOptions, filepath.Join(workDir, "zitadel-cert-job.yaml")) + k8s.WaitUntilJobSucceed(t, cfg.KubeOptions, "create-zitadel-cert", 120, 3*time.Second) + }, + nil, + )) +} + +func TestReferencedSecrets(t *testing.T) { + t.Parallel() + example := "5-referenced-secrets" + workDir, valuesFile, values := readConfig(t, example) + cfg := values.Zitadel.ConfigmapConfig + suite.Run(t, acceptance.Configure( + t, + newNamespaceIdentifier(example), + acceptance.Postgres.WithValues(filepath.Join(workDir, "postgres-values.yaml")), + []string{valuesFile}, + cfg.ExternalDomain, + cfg.ExternalPort, + cfg.ExternalSecure, + nil, + func(cfg *acceptance.ConfigurationTest) { + k8s.KubectlApply(t, cfg.KubeOptions, filepath.Join(workDir, "zitadel-secrets.yaml")) + k8s.KubectlApply(t, cfg.KubeOptions, filepath.Join(workDir, "zitadel-masterkey.yaml")) + }, + nil, + )) +} + +func TestMachineUser(t *testing.T) { + t.Parallel() + example := "6-machine-user" + workDir, valuesFile, values := readConfig(t, example) + cfg := values.Zitadel.ConfigmapConfig + saUsername := cfg.FirstInstance.Org.Machine.Machine.Username + suite.Run(t, acceptance.Configure( + t, + newNamespaceIdentifier(example), + acceptance.Postgres.WithValues(filepath.Join(workDir, "postgres-values.yaml")), + []string{valuesFile}, + cfg.ExternalDomain, + cfg.ExternalPort, + cfg.ExternalSecure, + nil, + nil, + testAuthenticatedAPI(saUsername, fmt.Sprintf("%s.json", saUsername))), + ) +} + +func TestSelfSigned(t *testing.T) { + t.Parallel() + example := "7-self-signed" + workDir, valuesFile, values := readConfig(t, example) + cfg := values.Zitadel.ConfigmapConfig + suite.Run(t, acceptance.Configure( + t, + newNamespaceIdentifier(example), + acceptance.Postgres.WithValues(filepath.Join(workDir, "postgres-values.yaml")), + []string{valuesFile}, + cfg.ExternalDomain, + cfg.ExternalPort, + cfg.ExternalSecure, + nil, + nil, + nil, + )) +} diff --git a/charts/zitadel/acceptance/accessibility.go b/charts/zitadel/acceptance/accessibility.go new file mode 100644 index 0000000..d489d9d --- /dev/null +++ b/charts/zitadel/acceptance/accessibility.go @@ -0,0 +1,138 @@ +package acceptance + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" + + mgmt_api "github.com/zitadel/zitadel-go/v2/pkg/client/zitadel/management" + + "github.com/gruntwork-io/terratest/modules/k8s" + corev1 "k8s.io/api/core/v1" +) + +type checkOptions interface { + execute(ctx context.Context) error +} + +type checkOptionsFunc func(ctx context.Context) error + +func (f checkOptionsFunc) execute(ctx context.Context) error { + return f(ctx) +} + +type httpCheckOptions struct { + getUrl string + test func(response *http.Response, body []byte) error +} + +func (c *httpCheckOptions) execute(ctx context.Context) (err error) { + checkCtx, checkCancel := context.WithTimeout(ctx, 5*time.Second) + defer checkCancel() + //nolint:bodyclose + resp, body, err := HttpGet(checkCtx, c.getUrl, nil) + if err != nil { + return fmt.Errorf("HttpGet failed with response %+v and body %+v: %w", resp, body, err) + } + if err = c.test(resp, body); err != nil { + return fmt.Errorf("checking response %+v with body %+v failed: %w", resp, body, err) + } + return nil +} + +func (s *ConfigurationTest) checkAccessibility(pods []corev1.Pod) { + ctx, cancel := context.WithTimeout(s.Ctx, time.Minute) + defer cancel() + apiBaseURL := s.APIBaseURL() + tunnels := []interface{ Close() }{CloseFunc(ServiceTunnel(s))} + defer func() { + for _, t := range tunnels { + t.Close() + } + }() + checks := append( + zitadelStatusChecks(s.Scheme, s.Domain, s.Port), + &httpCheckOptions{ + getUrl: apiBaseURL + "/ui/console/assets/environment.json", + test: func(resp *http.Response, body []byte) error { + if err := checkHttpStatus200(resp, body); err != nil { + return err + } + bodyStr := string(body) + for _, expect := range []string{ + fmt.Sprintf(`"api":"%s"`, apiBaseURL), + fmt.Sprintf(`"issuer":"%s"`, apiBaseURL), + } { + if !strings.Contains(bodyStr, expect) { + return fmt.Errorf("couldn't find %s in environment.json content %s", expect, bodyStr) + } + } + return nil + }, + }, + checkOptionsFunc(func(ctx context.Context) error { + randomInvalidKey := `{"type":"serviceaccount","keyId":"229185755715993707","key":"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAm8bpVfzWJuZsEz1VfTrwSAdkbH+i/u2NS4dv60lwIjtXzrU7\n1xZkHw9jxqz+c+APTaTzp1KY49Dc/wcwXv032FuD1GK2ZSRnMaHm8QnNt8Xhi0e8\nBlu3QQmlqxWPCI67wDPUwXoSHM+r9gQXn2pOR0oonoLP+Gzef+RRj1zUFpZmHWPX\nxw4UWWHwl4xChw9iyO4HbZZGe6wBVYVWe2BnvviCVEeKapyjaCqokZES38S4+S2X\nit202xLlRDyXs3XFWmBzHGmEsxx3LZZor85Kbph/bGjDcV8rdQC1YKC++z8OhuLp\n79GltP7YWrfMN3Z8iRUJQY9APrKQYtljVkWrnQIDAQABAoIBAQCIRZrLyRHCF+LF\ndes6UPvv1t+n9oQtRLxNLV7f0m+Q0p7+yhZeE01kyn67R4yU65YXk0w+vIfZC1a4\nlp5fCl73Gx+ZBP2QPyczCPHRPIVE1Yt33zoByevmrjzKDGMC1nIyMmVVF6eOorFI\n1s2ffEycGqir+b1bEkoWUTJ0Gn3Cf1PE4vTgenHhCrYSvMsbmszQ5GDlfxNj27qf\nF2YrnLx11GplMYU0YEzGqSQHxw76rrmF7yiTvbB+olsjXWARAJxBriSlrF2BDYQk\n+HJ8MEwhWhncaZH1i0Xz/jarDBizpo2o1+K1ZqF6RBUknT72EPnMxI9JsvS4FH44\nZfbrujBhAoGBAMQnx6tO79GpnBIAr7iELyUu5F4mCdU6D0rOAiCjXPpCUAdCDuwX\nzROonIGXPPmhzXXtxebeTz4cf+P8p6tUnrqpl/f0Oi1DMOzv0jL/SAUDC9uUrg6k\nurXZT2dgeONwd1pADyNXSpbZfwRE5IoecFg6cgFi4kune0mdG3mr8QjpAoGBAMtN\nerrMc+4bc3GsmWG4FSXn3xlWMeVGIo2/owP2P5MuMu0ibjofZkl28y0xo8dJgWmv\nLiFSEOhUy+TXZK7K1a2+fD+AXHHaHkBjNbTmCaAbf7rZnuUL4iZVpQyIoTCVuAwo\nC6bsE4TcwGddk4yZj/WZ7v1be+uNgeYwQr2UshyVAoGAN8pYsBCzhR6IlVY8pG50\nOk8sBNss0MjCsLQHRuEwAL37pRTUybG7UmwSl4k8foPWvEP0lcWFJFVWyrGBvulC\nfDTgVFXSdi02LS3Iy1hwU3yaUsnm96NCt5YnT2/Q8l96kuDFbXfWbzFNPxmZJu+h\nZHa7FknZs0rfdgCJYAHXfIECgYEAw3kSqSrNyMICJOkkbO2W/+RLAUx8GwttS8dX\nkQaip/wCoTi6rQ3lxnslY23YIFRPpvL1srn6YbiudrCXMOz7uNtvEYt01082SQha\n6j1IQfZOwLRfb7EWV29/i2aPPWynEqEqWuuf9N5f7MLvjH9WCHpibJ4aryhXHqGG\nekvPWWUCgYA5qDsPk5ykRWEALbunzB/RkpxR6LTLSwriU/OzRswOiKo8UPqH4JZI\nOsFAgudG5H+UOEGMuaSvIq0PLbGex16PjKqUsRwgIoPdH8183f9fxZSJDmr7ELIy\nZJEvE3eJnYwMOpSEZS0VR5Sw0CmKV2Hhd+u6rRB8YjXMP0nAVg8eOA==\n-----END RSA PRIVATE KEY-----\n","userId":"229185755715600491"}` + conn, err := OpenGRPCConnection(s, []byte(randomInvalidKey)) + if errors.As(err, &x509.UnknownAuthorityError{}) { + // The gRPC client doesn't support skipping the server cert validation + return nil + } + if err != nil { + return fmt.Errorf("couldn't create gRPC management client: %w", err) + } + _, err = conn.Healthz(ctx, &mgmt_api.HealthzRequest{}) + // TODO: Why is the key checked on the healthz RPC? + if strings.Contains(err.Error(), "Errors.AuthNKey.NotFound") || + strings.Contains(err.Error(), "Errors.User.NotFound") || + strings.Contains(err.Error(), "assertion invalid") { + err = nil + } + return err + })) + for i := range pods { + pod := pods[i] + podTunnel := k8s.NewTunnel(s.KubeOptions, k8s.ResourceTypePod, pod.Name, 0, 8080) + podTunnel.ForwardPort(s.T()) + tunnels = append(tunnels, podTunnel) + localPort, err := strconv.ParseUint(strings.Split(podTunnel.Endpoint(), ":")[1], 10, 16) + if err != nil { + s.T().Fatal(err) + } + checks = append(checks, zitadelStatusChecks(s.Scheme, s.Domain, uint16(localPort))...) + } + wg := sync.WaitGroup{} + for _, check := range checks { + wg.Add(1) + go Await(ctx, s.T(), &wg, 60, check.execute) + } + wait(ctx, s.T(), &wg, "accessibility") +} + +func zitadelStatusChecks(scheme, domain string, port uint16) []checkOptions { + return []checkOptions{ + &httpCheckOptions{ + getUrl: fmt.Sprintf("%s://%s:%d/debug/validate", scheme, domain, port), + test: checkHttpStatus200, + }, + &httpCheckOptions{ + getUrl: fmt.Sprintf("%s://%s:%d/debug/healthz", scheme, domain, port), + test: checkHttpStatus200, + }, + &httpCheckOptions{ + getUrl: fmt.Sprintf("%s://%s:%d/debug/ready", scheme, domain, port), + test: checkHttpStatus200, + }} +} + +func checkHttpStatus200(resp *http.Response, _ []byte) error { + if resp.StatusCode != 200 { + return fmt.Errorf("expected status code 200 but got %d", resp.StatusCode) + } + return nil +} diff --git a/charts/zitadel/acceptance/after.go b/charts/zitadel/acceptance/after.go new file mode 100644 index 0000000..4c66ca0 --- /dev/null +++ b/charts/zitadel/acceptance/after.go @@ -0,0 +1,8 @@ +package acceptance + +func (s *ConfigurationTest) AfterTest(_, _ string) { + if s.afterZITADELFunc == nil || s.T().Failed() { + return + } + s.afterZITADELFunc(s) +} diff --git a/charts/zitadel/acceptance/authenticate_test.go b/charts/zitadel/acceptance/authenticate_test.go new file mode 100644 index 0000000..1668064 --- /dev/null +++ b/charts/zitadel/acceptance/authenticate_test.go @@ -0,0 +1,103 @@ +package acceptance_test + +import ( + "context" + "encoding/json" + "fmt" + "github.com/gruntwork-io/terratest/modules/k8s" + "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/zitadel-charts/charts/zitadel/acceptance" + mgmt_api "github.com/zitadel/zitadel-go/v2/pkg/client/zitadel/management" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +func testAuthenticatedAPI(secretName, secretKey string) func(test *acceptance.ConfigurationTest) { + return func(cfg *acceptance.ConfigurationTest) { + t := cfg.T() + apiBaseURL := cfg.APIBaseURL() + secret := k8s.GetSecret(t, cfg.KubeOptions, secretName) + key := secret.Data[secretKey] + if key == nil { + t.Fatalf("key %s in secret %s is nil", secretKey, secretName) + } + jwta, err := oidc.NewJWTProfileAssertionFromFileData(key, []string{apiBaseURL}) + if err != nil { + t.Fatal(err) + } + jwt, err := oidc.GenerateJWTProfileToken(jwta) + if err != nil { + t.Fatal(err) + } + closeTunnel := acceptance.ServiceTunnel(cfg) + defer closeTunnel() + var token string + acceptance.Await(cfg.Ctx, t, nil, 60, func(ctx context.Context) error { + var tokenErr error + token, tokenErr = getToken(ctx, t, jwt, apiBaseURL) + return tokenErr + }) + acceptance.Await(cfg.Ctx, t, nil, 60, func(ctx context.Context) error { + if httpErr := callAuthenticatedHTTPEndpoint(ctx, token, apiBaseURL); httpErr != nil { + return httpErr + } + return callAuthenticatedGRPCEndpoint(cfg, key) + }) + } +} + +func getToken(ctx context.Context, t *testing.T, jwt, apiBaseURL string) (string, error) { + form := url.Values{} + form.Add("grant_type", string(oidc.GrantTypeBearer)) + form.Add("scope", fmt.Sprintf("%s %s %s urn:zitadel:iam:org:project:id:zitadel:aud", oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail)) + form.Add("assertion", jwt) + //nolint:bodyclose + resp, tokenBody, err := acceptance.HttpPost(ctx, fmt.Sprintf("%s/oauth/v2/token", apiBaseURL), func(req *http.Request) { + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + }, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + if resp.StatusCode != 200 { + return "", fmt.Errorf("expected token response 200, but got %d", resp.StatusCode) + } + token := struct { + AccessToken string `json:"access_token"` + }{} + if err = json.Unmarshal(tokenBody, &token); err != nil { + t.Fatal(err) + } + return token.AccessToken, nil +} + +func callAuthenticatedHTTPEndpoint(ctx context.Context, token, apiBaseURL string) error { + checkCtx, checkCancel := context.WithTimeout(ctx, 5*time.Second) + defer checkCancel() + //nolint:bodyclose + resp, _, err := acceptance.HttpGet(checkCtx, fmt.Sprintf("%s/management/v1/languages", apiBaseURL), func(req *http.Request) { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + }) + if err != nil { + return err + } + if resp.StatusCode != 200 { + return fmt.Errorf("expected status 200 at an authenticated endpoint, but got %d", resp.StatusCode) + } + return nil +} + +func callAuthenticatedGRPCEndpoint(cfg *acceptance.ConfigurationTest, key []byte) error { + t := cfg.T() + conn, err := acceptance.OpenGRPCConnection(cfg, key) + if err != nil { + return fmt.Errorf("couldn't open gRPC connection: %v", err) + } + _, err = conn.GetSupportedLanguages(cfg.Ctx, &mgmt_api.GetSupportedLanguagesRequest{}) + if err != nil { + t.Fatalf("couldn't call authenticated gRPC endpoint: %v", err) + } + return nil +} diff --git a/charts/zitadel/acceptance/availability.go b/charts/zitadel/acceptance/availability.go new file mode 100644 index 0000000..dd3ee66 --- /dev/null +++ b/charts/zitadel/acceptance/availability.go @@ -0,0 +1,24 @@ +package acceptance + +import ( + "context" + "sync" + "time" + + "github.com/gruntwork-io/terratest/modules/k8s" + corev1 "k8s.io/api/core/v1" +) + +func (s *ConfigurationTest) awaitReadiness(pods []corev1.Pod) { + ctx, cancel := context.WithTimeout(s.Ctx, 5*time.Minute) + defer cancel() + wg := sync.WaitGroup{} + for _, p := range pods { + wg.Add(1) + go func(pod corev1.Pod) { + k8s.WaitUntilPodAvailable(s.T(), s.KubeOptions, pod.Name, 300, time.Second) + wg.Done() + }(p) + } + wait(ctx, s.T(), &wg, "readiness") +} diff --git a/charts/zitadel/acceptance/await.go b/charts/zitadel/acceptance/await.go new file mode 100644 index 0000000..dbe6383 --- /dev/null +++ b/charts/zitadel/acceptance/await.go @@ -0,0 +1,24 @@ +package acceptance + +import ( + "context" + "sync" + "testing" + "time" +) + +func Await(ctx context.Context, t *testing.T, wg *sync.WaitGroup, tries int, cb func(ctx context.Context) error) { + err := cb(ctx) + if err == nil { + if wg != nil { + wg.Done() + } + return + } + if tries == 0 { + t.Fatal(err) + } + t.Logf("got error %v. trying again in a second", err) + time.Sleep(time.Second) + Await(ctx, t, wg, tries-1, cb) +} diff --git a/charts/zitadel/acceptance/before.go b/charts/zitadel/acceptance/before.go new file mode 100644 index 0000000..d308aa7 --- /dev/null +++ b/charts/zitadel/acceptance/before.go @@ -0,0 +1,23 @@ +package acceptance + +import "github.com/gruntwork-io/terratest/modules/helm" + +func (s *ConfigurationTest) BeforeTest(_, _ string) { + if s.beforeFunc != nil { + s.beforeFunc(s) + } + helm.AddRepo(s.T(), &helm.Options{}, s.dbRepoName, s.dbChart.repoUrl) + options := &helm.Options{ + KubectlOptions: s.KubeOptions, + Version: s.dbChart.version, + SetValues: s.dbChart.testValues, + ExtraArgs: map[string][]string{"install": {"--wait"}}, + } + if s.dbChart.valuesFile != "" { + options.ValuesFiles = []string{s.dbChart.valuesFile} + } + helm.Install(s.T(), options, s.dbRepoName+"/"+s.dbChart.name, s.dbRelease) + if s.afterDBFunc != nil { + s.afterDBFunc(s) + } +} diff --git a/charts/zitadel/acceptance/config.go b/charts/zitadel/acceptance/config.go new file mode 100644 index 0000000..909b8e0 --- /dev/null +++ b/charts/zitadel/acceptance/config.go @@ -0,0 +1,106 @@ +package acceptance + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/gruntwork-io/terratest/modules/k8s" + "github.com/gruntwork-io/terratest/modules/logger" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "k8s.io/client-go/kubernetes" +) + +type hookFunc func(*ConfigurationTest) + +type ConfigurationTest struct { + suite.Suite + Ctx context.Context + log *logger.Logger + KubeOptions *k8s.KubectlOptions + KubeClient *kubernetes.Clientset + Scheme, Domain string + Port uint16 + zitadelValues []string + dbChart databaseChart + zitadelChartPath, zitadelRelease, dbRepoName, dbRelease string + beforeFunc, afterDBFunc, afterZITADELFunc hookFunc +} + +func (c *ConfigurationTest) APIBaseURL() string { + return fmt.Sprintf(`%s://%s:%d`, c.Scheme, c.Domain, c.Port) +} + +type databaseChart struct { + valuesFile, repoUrl, name, version string + testValues map[string]string +} + +var ( + Cockroach = databaseChart{ + repoUrl: "https://charts.cockroachdb.com/", + name: "cockroachdb", + version: "13.0.1", + testValues: map[string]string{ + "statefulset.replicas": "1", + "conf.single-node": "true", + }, + } + Postgres = databaseChart{ + repoUrl: "https://charts.bitnami.com/bitnami", + name: "postgresql", + version: "12.10.0", + } +) + +func (d *databaseChart) WithValues(valuesFile string) databaseChart { + d.valuesFile = valuesFile + return *d +} + +func Configure( + t *testing.T, + namespace string, + dbChart databaseChart, + zitadelValues []string, + externalDomain string, + externalPort uint16, + externalSecure bool, + before, afterDB, afterZITADEL hookFunc, +) *ConfigurationTest { + chartPath, err := filepath.Abs("..") + require.NoError(t, err) + dbRepoName := fmt.Sprintf("crdb-%s", strings.TrimPrefix(namespace, "zitadel-helm-")) + kubeOptions := k8s.NewKubectlOptions("", "", namespace) + clientset, err := k8s.GetKubernetesClientFromOptionsE(t, kubeOptions) + if err != nil { + t.Fatal(err) + } + externalScheme := "http" + if externalSecure { + externalScheme = "https" + } + cfg := &ConfigurationTest{ + Ctx: context.Background(), + log: logger.New(logger.Terratest), + KubeOptions: kubeOptions, + KubeClient: clientset, + zitadelValues: zitadelValues, + zitadelChartPath: chartPath, + zitadelRelease: "zitadel-test", + dbChart: dbChart, + dbRepoName: dbRepoName, + dbRelease: "db", + beforeFunc: before, + afterDBFunc: afterDB, + afterZITADELFunc: afterZITADEL, + Domain: externalDomain, + Port: externalPort, + Scheme: externalScheme, + } + cfg.SetT(t) + return cfg +} diff --git a/charts/zitadel/acceptance/config_test.go b/charts/zitadel/acceptance/config_test.go new file mode 100644 index 0000000..c15d983 --- /dev/null +++ b/charts/zitadel/acceptance/config_test.go @@ -0,0 +1,81 @@ +package acceptance_test + +import ( + "fmt" + "github.com/gruntwork-io/terratest/modules/random" + "gopkg.in/yaml.v3" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +type Values struct { + Zitadel struct { + MasterkeySecretName string `yaml:"masterkeySecretName"` + ConfigSecretName string `yaml:"configSecretName"` + ConfigmapConfig struct { + ExternalDomain string `yaml:"ExternalDomain"` + ExternalPort uint16 `yaml:"ExternalPort"` + ExternalSecure bool `yaml:"ExternalSecure"` + FirstInstance struct { + Org struct { + Machine struct { + Machine struct { + Username string `yaml:"Username"` + } `yaml:"Machine"` + } `yaml:"Machine"` + } `yaml:"Org"` + } `yaml:"FirstInstance"` + } `yaml:"configmapConfig"` + } `yaml:"zitadel"` +} + +func readValues(t *testing.T, valuesFilePath string) (values Values) { + // set default values like in the defaults.yaml + values.Zitadel.ConfigmapConfig.ExternalDomain = "localhost" + values.Zitadel.ConfigmapConfig.ExternalPort = 8080 + values.Zitadel.ConfigmapConfig.ExternalSecure = true + valuesBytes, err := os.ReadFile(valuesFilePath) + if err != nil { + t.Fatal(err) + } + if err := yaml.Unmarshal(valuesBytes, &values); err != nil { + t.Fatal(err) + } + return values +} + +func newNamespaceIdentifier(testcase string) string { + // if triggered by a github action the environment variable is set + // we use it to better identify the test + commitSHA, exist := os.LookupEnv("GITHUB_SHA") + namespace := fmt.Sprintf("zitadel-test-%s-%s", testcase, strings.ToLower(random.UniqueId())) + if exist { + namespace += "-" + commitSHA + } + // max namespace length is 63 characters + // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names + return truncateString(namespace, 63) +} + +func truncateString(str string, num int) string { + shortenStr := str + if len(str) > num { + shortenStr = str[0:num] + } + return shortenStr +} + +func workingDirectory(exampleDir string) (workingDir, valuesFile string) { + _, filename, _, _ := runtime.Caller(0) + workingDir = filepath.Join(filename, "..", "..", "..", "..", "examples", exampleDir) + valuesFile = filepath.Join(workingDir, "zitadel-values.yaml") + return workingDir, valuesFile +} + +func readConfig(t *testing.T, exampleDir string) (string, string, Values) { + workingDir, valuesFile := workingDirectory(exampleDir) + return workingDir, valuesFile, readValues(t, valuesFile) +} diff --git a/charts/zitadel/acceptance/grpc.go b/charts/zitadel/acceptance/grpc.go new file mode 100644 index 0000000..fe2f705 --- /dev/null +++ b/charts/zitadel/acceptance/grpc.go @@ -0,0 +1,19 @@ +package acceptance + +import ( + "fmt" + "github.com/zitadel/zitadel-go/v2/pkg/client/management" + "github.com/zitadel/zitadel-go/v2/pkg/client/middleware" + "github.com/zitadel/zitadel-go/v2/pkg/client/zitadel" +) + +func OpenGRPCConnection(cfg *ConfigurationTest, key []byte) (*management.Client, error) { + conn, err := management.NewClient( + cfg.APIBaseURL(), + fmt.Sprintf("%s:%d", cfg.Domain, cfg.Port), + []string{zitadel.ScopeZitadelAPI()}, + zitadel.WithJWTProfileTokenSource(middleware.JWTProfileFromFileData(key)), + zitadel.WithInsecure(), + ) + return conn, err +} diff --git a/charts/zitadel/acceptance/request.go b/charts/zitadel/acceptance/request.go new file mode 100644 index 0000000..e9ae9ae --- /dev/null +++ b/charts/zitadel/acceptance/request.go @@ -0,0 +1,35 @@ +package acceptance + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net/http" +) + +func HttpGet(ctx context.Context, url string, beforeSend func(req *http.Request)) (*http.Response, []byte, error) { + return httpCall(ctx, http.MethodGet, url, beforeSend, nil) +} + +func HttpPost(ctx context.Context, url string, beforeSend func(req *http.Request), body io.Reader) (*http.Response, []byte, error) { + return httpCall(ctx, http.MethodPost, url, beforeSend, body) +} + +func httpCall(ctx context.Context, method string, url string, beforeSend func(req *http.Request), requestBody io.Reader) (*http.Response, []byte, error) { + req, err := http.NewRequestWithContext(ctx, method, url, requestBody) + if err != nil { + return nil, nil, fmt.Errorf("creating request for url %s failed: %s", url, err.Error()) + } + if beforeSend != nil { + beforeSend(req) + } + httpClient := http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} + resp, err := httpClient.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("sending request %+v failed: %s", *req, err) + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(resp.Body) + return resp, responseBody, err +} diff --git a/charts/zitadel/acceptance/run.go b/charts/zitadel/acceptance/run.go new file mode 100644 index 0000000..8934e20 --- /dev/null +++ b/charts/zitadel/acceptance/run.go @@ -0,0 +1,48 @@ +package acceptance + +import ( + "testing" + "time" + + "github.com/gruntwork-io/terratest/modules/helm" + "github.com/gruntwork-io/terratest/modules/k8s" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (s *ConfigurationTest) TestZITADELInstallation() { + helm.Install(s.T(), &helm.Options{ + KubectlOptions: s.KubeOptions, + ValuesFiles: s.zitadelValues, + SetValues: map[string]string{ + "replicaCount": "1", + "pdb.enabled": "true", + }, + }, s.zitadelChartPath, s.zitadelRelease) + k8s.WaitUntilJobSucceed(s.T(), s.KubeOptions, "zitadel-test-init", 900, time.Second) + k8s.WaitUntilJobSucceed(s.T(), s.KubeOptions, "zitadel-test-setup", 900, time.Second) + pods := listPods(s.T(), 5, s.KubeOptions) + s.awaitReadiness(pods) + zitadelPods := make([]corev1.Pod, 0) + for i := range pods { + pod := pods[i] + if name, ok := pod.GetObjectMeta().GetLabels()["app.kubernetes.io/name"]; ok && name == "zitadel" { + zitadelPods = append(zitadelPods, pod) + } + } + s.log.Logf(s.T(), "ZITADEL pods are ready") + s.checkAccessibility(zitadelPods) +} + +// listPods retries until all three start pods are returned from the kubeapi +func listPods(t *testing.T, try int, kubeOptions *k8s.KubectlOptions) []corev1.Pod { + if try == 0 { + t.Fatal("no trials left") + } + pods := k8s.ListPods(t, kubeOptions, metav1.ListOptions{LabelSelector: `app.kubernetes.io/instance=zitadel-test, app.kubernetes.io/component=start`}) + if len(pods) == 1 { + return pods + } + time.Sleep(time.Second) + return listPods(t, try-1, kubeOptions) +} diff --git a/charts/zitadel/acceptance/service_tunnel.go b/charts/zitadel/acceptance/service_tunnel.go new file mode 100644 index 0000000..6e65619 --- /dev/null +++ b/charts/zitadel/acceptance/service_tunnel.go @@ -0,0 +1,39 @@ +package acceptance + +import ( + "context" + "fmt" + "github.com/gruntwork-io/terratest/modules/k8s" + "net" +) + +type CloseFunc func() + +func (c CloseFunc) Close() { + c() +} + +// ServiceTunnel must be closed using the returned close function +func ServiceTunnel(cfg *ConfigurationTest) func() { + serviceTunnel := k8s.NewTunnel(cfg.KubeOptions, k8s.ResourceTypeService, cfg.zitadelRelease, int(cfg.Port), 8080) + awaitServicePortForward(cfg, serviceTunnel) + return serviceTunnel.Close +} + +func awaitServicePortForward(cfg *ConfigurationTest, tunnel *k8s.Tunnel) { + t := cfg.T() + addr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.Port)) + if err != nil { + t.Fatal(err) + } + Await(cfg.Ctx, t, nil, 600, func(ctx context.Context) error { + l, err := net.ListenTCP("tcp", addr) + if err != nil { + return err + } + if err := l.Close(); err != nil { + panic(err) + } + return tunnel.ForwardPortE(cfg.T()) + }) +} diff --git a/charts/zitadel/acceptance/setup.go b/charts/zitadel/acceptance/setup.go new file mode 100644 index 0000000..bae5a26 --- /dev/null +++ b/charts/zitadel/acceptance/setup.go @@ -0,0 +1,50 @@ +package acceptance + +import ( + "github.com/gruntwork-io/terratest/modules/k8s" + "github.com/jinzhu/copier" + "k8s.io/apimachinery/pkg/api/errors" + "strings" +) + +func (s *ConfigurationTest) SetupTest() { + clusterKubectl := new(k8s.KubectlOptions) + t := s.T() + if err := copier.Copy(clusterKubectl, s.KubeOptions); err != nil { + t.Fatal(err) + } + clusterKubectl.Namespace = "" + if err := k8s.KubectlDeleteFromStringE(t, clusterKubectl, ` +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: crdb +`); err != nil && !isNotFoundFromKubectl(err) { + t.Fatal(err) + } + if err := k8s.KubectlDeleteFromStringE(t, clusterKubectl, ` +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: crdb +`); err != nil && !isNotFoundFromKubectl(err) { + t.Fatal(err) + } + _, err := k8s.GetNamespaceE(t, s.KubeOptions, s.KubeOptions.Namespace) + notFound := errors.IsNotFound(err) + if err != nil && !notFound { + t.Fatal(err) + } + if notFound { + k8s.CreateNamespace(t, s.KubeOptions, s.KubeOptions.Namespace) + return + } + s.log.Logf(s.T(), "Namespace: %s already exist!", s.KubeOptions.Namespace) +} + +func isNotFoundFromKubectl(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "not found") +} diff --git a/charts/zitadel/acceptance/teardown.go b/charts/zitadel/acceptance/teardown.go new file mode 100644 index 0000000..f52981f --- /dev/null +++ b/charts/zitadel/acceptance/teardown.go @@ -0,0 +1,11 @@ +package acceptance + +import "github.com/gruntwork-io/terratest/modules/k8s" + +func (s *ConfigurationTest) TearDownTest() { + if !s.T().Failed() { + k8s.DeleteNamespace(s.T(), s.KubeOptions, s.KubeOptions.Namespace) + } else { + s.log.Logf(s.T(), "Test failed on namespace %s. Omitting cleanup.", s.KubeOptions.Namespace) + } +} diff --git a/charts/zitadel/acceptance/wait.go b/charts/zitadel/acceptance/wait.go new file mode 100644 index 0000000..50fa681 --- /dev/null +++ b/charts/zitadel/acceptance/wait.go @@ -0,0 +1,26 @@ +package acceptance + +import ( + "context" + "sync" + "testing" +) + +func wait(ctx context.Context, t *testing.T, wg *sync.WaitGroup, waitFor string) { + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + for { + select { + case <-done: + return + case <-ctx.Done(): + if err := ctx.Err(); err != nil { + t.Fatalf("awaiting %s failed: %s", waitFor, err) + } + return + } + } +}