Explorar o código

test: add Infisical provider e2e suite (#6415)

Co-authored-by: Gergely Bräutigam <gergely.brautigam@sap.com>
Signed-off-by: Alexander Chernov <alexander@chernov.it>
Alexander Chernov hai 1 semana
pai
achega
6c3cdb8ef5

+ 392 - 0
e2e/framework/addon/infisical.go

@@ -0,0 +1,392 @@
+/*
+Copyright © The ESO 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
+
+    https://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 addon
+
+import (
+	"bytes"
+	"context"
+	"crypto/rand"
+	"encoding/base64"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"path/filepath"
+	"time"
+
+	infisicalSdk "github.com/infisical/go-sdk"
+	. "github.com/onsi/ginkgo/v2"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+)
+
+const (
+	infisicalNamespace   = "infisical"
+	infisicalReleaseName = "infisical"
+	// infisicalServiceName must match infisical.fullnameOverride in
+	// infisical.values.yaml so we can port-forward and target it by DNS.
+	infisicalServiceName = "infisical-backend"
+	infisicalAPIPort     = 8080
+	infisicalChartVer    = "1.8.0"
+	infisicalSecretName  = "infisical-secrets"
+
+	// Bootstrap identity used to provision the instance. The password meets
+	// Infisical's complexity policy (length + mixed character classes).
+	infisicalAdminEmail    = "admin@example.com"
+	infisicalAdminPassword = "E2eAdminPassw0rd!23"
+	infisicalOrgName       = "eso-e2e"
+	infisicalProjectName   = "eso-e2e"
+	infisicalProjectSlug   = "eso-e2e-tests"
+)
+
+// Infisical deploys a self-hosted Infisical into the cluster and provisions a
+// project plus a Universal Auth machine identity for the e2e suite to use.
+type Infisical struct {
+	chart         *HelmChart
+	Namespace     string
+	portForwarder *PortForward
+	httpClient    *http.Client
+	cancelRefresh context.CancelFunc
+
+	// HostAPI is the in-cluster API URL ESO uses from the SecretStore.
+	HostAPI string
+	// localBaseURL is the port-forwarded API URL the test process uses.
+	localBaseURL string
+
+	ClientID        string
+	ClientSecret    string
+	ProjectID       string
+	ProjectSlug     string
+	EnvironmentSlug string
+
+	// SDKClient is logged in via Universal Auth and used by the suite to seed
+	// and remove backend secrets (the provider itself is read-only).
+	SDKClient infisicalSdk.InfisicalClientInterface
+}
+
+func NewInfisical() *Infisical {
+	repo := "infisical-helm-charts"
+	return &Infisical{
+		Namespace:  infisicalNamespace,
+		httpClient: &http.Client{Timeout: 30 * time.Second},
+		chart: &HelmChart{
+			Namespace:    infisicalNamespace,
+			ReleaseName:  infisicalReleaseName,
+			Chart:        fmt.Sprintf("%s/infisical-standalone", repo),
+			ChartVersion: infisicalChartVer,
+			Repo: ChartRepo{
+				Name: repo,
+				URL:  "https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/",
+			},
+			Values: []string{filepath.Join(AssetDir(), "infisical.values.yaml")},
+		},
+	}
+}
+
+func (l *Infisical) Setup(cfg *Config) error {
+	return l.chart.Setup(cfg)
+}
+
+func (l *Infisical) Install() (err error) {
+	l.HostAPI = fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", infisicalServiceName, l.Namespace, infisicalAPIPort)
+
+	// Arm rollback before the first mutating step so any failure (secret,
+	// namespace, chart install, or bootstrap) tears down what was created and a
+	// re-run does not trip over a half-installed instance.
+	defer func() {
+		if err != nil {
+			l.cleanup()
+		}
+	}()
+
+	if err := l.createInstanceSecret(); err != nil {
+		return fmt.Errorf("unable to create infisical secret: %w", err)
+	}
+
+	if err := l.chart.Install(); err != nil {
+		return err
+	}
+
+	pf, err := NewPortForward(l.chart.config.KubeClientSet, l.chart.config.KubeConfig, infisicalServiceName, l.Namespace, infisicalAPIPort)
+	if err != nil {
+		return err
+	}
+	if err := pf.Start(); err != nil {
+		return err
+	}
+	l.portForwarder = pf
+	l.localBaseURL = fmt.Sprintf("http://localhost:%d", pf.localPort)
+
+	if err := l.waitForAPI(); err != nil {
+		return fmt.Errorf("infisical API never became ready: %w", err)
+	}
+
+	if err := l.bootstrap(); err != nil {
+		return fmt.Errorf("unable to bootstrap infisical: %w", err)
+	}
+
+	// Own the SDK client context so its token-refresh goroutine lives for the
+	// whole suite (not just this BeforeAll node) and is canceled on teardown.
+	ctx, cancel := context.WithCancel(context.Background())
+	l.cancelRefresh = cancel
+	client := infisicalSdk.NewInfisicalClient(ctx, infisicalSdk.Config{SiteUrl: l.localBaseURL})
+	if _, err := client.Auth().UniversalAuthLogin(l.ClientID, l.ClientSecret); err != nil {
+		return fmt.Errorf("unable to log in with universal auth: %w", err)
+	}
+	l.SDKClient = client
+
+	return nil
+}
+
+// cleanup best-effort removes everything Install created. It runs when a step
+// after the chart install fails, so repeated local runs do not collide with a
+// leftover release or namespace.
+func (l *Infisical) cleanup() {
+	if l.cancelRefresh != nil {
+		l.cancelRefresh()
+		l.cancelRefresh = nil
+	}
+	if l.portForwarder != nil {
+		l.portForwarder.Close()
+		l.portForwarder = nil
+	}
+	_ = l.chart.Uninstall()
+	_ = l.chart.config.KubeClientSet.CoreV1().Namespaces().Delete(GinkgoT().Context(), l.Namespace, metav1.DeleteOptions{})
+}
+
+// createInstanceSecret creates the Secret the chart mounts as envFrom. Infisical
+// requires a 16-byte hex ENCRYPTION_KEY and a base64 AUTH_SECRET.
+func (l *Infisical) createInstanceSecret() error {
+	encKey := make([]byte, 16)
+	if _, err := rand.Read(encKey); err != nil {
+		return err
+	}
+	authSecret := make([]byte, 32)
+	if _, err := rand.Read(authSecret); err != nil {
+		return err
+	}
+
+	ns := &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: l.Namespace}}
+	if _, err := controllerutil.CreateOrUpdate(GinkgoT().Context(), l.chart.config.CRClient, ns, func() error { return nil }); err != nil {
+		return err
+	}
+
+	sec := &v1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      infisicalSecretName,
+			Namespace: l.Namespace,
+		},
+	}
+	_, err := controllerutil.CreateOrUpdate(GinkgoT().Context(), l.chart.config.CRClient, sec, func() error {
+		sec.StringData = map[string]string{
+			"ENCRYPTION_KEY": hex.EncodeToString(encKey),
+			"AUTH_SECRET":    base64.StdEncoding.EncodeToString(authSecret),
+			"SITE_URL":       l.HostAPI,
+		}
+		return nil
+	})
+	return err
+}
+
+func (l *Infisical) waitForAPI() error {
+	deadline := time.Now().Add(3 * time.Minute)
+	for time.Now().Before(deadline) {
+		resp, err := l.httpClient.Get(l.localBaseURL + "/api/status")
+		if err == nil {
+			_ = resp.Body.Close()
+			if resp.StatusCode == http.StatusOK {
+				return nil
+			}
+		}
+		time.Sleep(3 * time.Second)
+	}
+	return fmt.Errorf("timed out waiting for %s/api/status", l.localBaseURL)
+}
+
+// bootstrap initializes a fresh instance and provisions everything the suite
+// needs: an admin identity, a project, and a Universal Auth machine identity.
+func (l *Infisical) bootstrap() error {
+	var boot struct {
+		Identity struct {
+			Credentials struct {
+				Token string `json:"token"`
+			} `json:"credentials"`
+		} `json:"identity"`
+		Organization struct {
+			ID string `json:"id"`
+		} `json:"organization"`
+	}
+	if err := l.apiCall(http.MethodPost, "/api/v1/admin/bootstrap", "", map[string]any{
+		"email":        infisicalAdminEmail,
+		"password":     infisicalAdminPassword,
+		"organization": infisicalOrgName,
+	}, &boot); err != nil {
+		return fmt.Errorf("admin bootstrap: %w", err)
+	}
+	token := boot.Identity.Credentials.Token
+
+	var project struct {
+		Project struct {
+			ID           string `json:"id"`
+			Slug         string `json:"slug"`
+			Environments []struct {
+				Slug string `json:"slug"`
+			} `json:"environments"`
+		} `json:"project"`
+	}
+	if err := l.apiCall(http.MethodPost, "/api/v1/projects", token, map[string]any{
+		"projectName":             infisicalProjectName,
+		"slug":                    infisicalProjectSlug,
+		"shouldCreateDefaultEnvs": true,
+	}, &project); err != nil {
+		return fmt.Errorf("create project: %w", err)
+	}
+	l.ProjectID = project.Project.ID
+	l.ProjectSlug = project.Project.Slug
+	l.EnvironmentSlug = pickEnvironment(project.Project.Environments)
+
+	// Create the machine identity, then grant it admin access to the project.
+	// The org role alone does not confer project access, so without the
+	// membership the identity gets a 403 on secret operations.
+	var identity struct {
+		Identity struct {
+			ID string `json:"id"`
+		} `json:"identity"`
+	}
+	if err := l.apiCall(http.MethodPost, "/api/v1/identities", token, map[string]any{
+		"name":           "eso-e2e",
+		"organizationId": boot.Organization.ID,
+		"role":           "member",
+	}, &identity); err != nil {
+		return fmt.Errorf("create identity: %w", err)
+	}
+	identityID := identity.Identity.ID
+
+	if err := l.apiCall(http.MethodPost, "/api/v2/workspace/"+l.ProjectID+"/identity-memberships/"+identityID, token, map[string]any{
+		"role": "admin",
+	}, nil); err != nil {
+		return fmt.Errorf("add identity to project: %w", err)
+	}
+
+	trustedIPs := []map[string]string{{"ipAddress": "0.0.0.0/0"}, {"ipAddress": "::/0"}}
+	var ua struct {
+		IdentityUniversalAuth struct {
+			ClientID string `json:"clientId"`
+		} `json:"identityUniversalAuth"`
+	}
+	if err := l.apiCall(http.MethodPost, "/api/v1/auth/universal-auth/identities/"+identityID, token, map[string]any{
+		"accessTokenTTL":          2592000,
+		"accessTokenMaxTTL":       2592000,
+		"accessTokenNumUsesLimit": 0,
+		"clientSecretTrustedIps":  trustedIPs,
+		"accessTokenTrustedIps":   trustedIPs,
+	}, &ua); err != nil {
+		return fmt.Errorf("attach universal auth: %w", err)
+	}
+	l.ClientID = ua.IdentityUniversalAuth.ClientID
+
+	var secret struct {
+		ClientSecret string `json:"clientSecret"`
+	}
+	if err := l.apiCall(http.MethodPost, "/api/v1/auth/universal-auth/identities/"+identityID+"/client-secrets", token, map[string]any{
+		"description":  "eso-e2e",
+		"numUsesLimit": 0,
+		"ttl":          0,
+	}, &secret); err != nil {
+		return fmt.Errorf("create client secret: %w", err)
+	}
+	l.ClientSecret = secret.ClientSecret
+
+	return nil
+}
+
+// pickEnvironment returns the "dev" environment slug when present, otherwise the
+// first environment the project was created with.
+func pickEnvironment(envs []struct {
+	Slug string `json:"slug"`
+}) string {
+	for _, e := range envs {
+		if e.Slug == "dev" {
+			return e.Slug
+		}
+	}
+	if len(envs) > 0 {
+		return envs[0].Slug
+	}
+	return "dev"
+}
+
+// apiCall performs a JSON request against the port-forwarded API, optionally
+// bearer-authenticated, and decodes the response into out.
+func (l *Infisical) apiCall(method, path, token string, body, out any) error {
+	var reader io.Reader
+	if body != nil {
+		b, err := json.Marshal(body)
+		if err != nil {
+			return err
+		}
+		reader = bytes.NewReader(b)
+	}
+
+	req, err := http.NewRequestWithContext(GinkgoT().Context(), method, l.localBaseURL+path, reader)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	if token != "" {
+		req.Header.Set("Authorization", "Bearer "+token)
+	}
+
+	resp, err := l.httpClient.Do(req)
+	if err != nil {
+		return err
+	}
+	defer func() { _ = resp.Body.Close() }()
+
+	data, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return fmt.Errorf("%s %s returned %d: %s", method, path, resp.StatusCode, string(data))
+	}
+	if out == nil {
+		return nil
+	}
+	return json.Unmarshal(data, out)
+}
+
+func (l *Infisical) Logs() error {
+	return l.chart.Logs()
+}
+
+func (l *Infisical) Uninstall() error {
+	if l.cancelRefresh != nil {
+		l.cancelRefresh()
+		l.cancelRefresh = nil
+	}
+	if l.portForwarder != nil {
+		l.portForwarder.Close()
+		l.portForwarder = nil
+	}
+	if err := l.chart.Uninstall(); err != nil {
+		return err
+	}
+	return l.chart.config.KubeClientSet.CoreV1().Namespaces().Delete(GinkgoT().Context(), l.Namespace, metav1.DeleteOptions{})
+}

+ 3 - 0
e2e/go.mod

@@ -61,6 +61,7 @@ require (
 	github.com/golang-jwt/jwt/v4 v4.5.2
 	github.com/grafana/grafana-openapi-client-go v0.0.0-20250925215610-d92957c70d5c
 	github.com/hashicorp/vault/api v1.22.0
+	github.com/infisical/go-sdk v0.5.100
 	github.com/onsi/ginkgo/v2 v2.27.2
 	github.com/onsi/gomega v1.38.2
 	github.com/oracle/oci-go-sdk/v65 v65.103.0
@@ -155,6 +156,7 @@ require (
 	github.com/go-openapi/swag/typeutils v0.25.4 // indirect
 	github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
 	github.com/go-openapi/validate v0.25.0 // indirect
+	github.com/go-resty/resty/v2 v2.13.1 // indirect
 	github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
 	github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
 	github.com/goccy/go-json v0.10.5 // indirect
@@ -179,6 +181,7 @@ require (
 	github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
 	github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
 	github.com/hashicorp/go-sockaddr v1.0.7 // indirect
+	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
 	github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
 	github.com/huandu/xstrings v1.5.0 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect

+ 16 - 0
e2e/go.sum

@@ -231,6 +231,8 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6
 github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
 github.com/go-openapi/validate v0.25.0 h1:JD9eGX81hDTjoY3WOzh6WqxVBVl7xjsLnvDo1GL5WPU=
 github.com/go-openapi/validate v0.25.0/go.mod h1:SUY7vKrN5FiwK6LyvSwKjDfLNirSfWwHNgxd2l29Mmw=
+github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g=
+github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0=
 github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
 github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
@@ -306,12 +308,16 @@ github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9
 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
 github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
 github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
 github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
 github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0=
 github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM=
 github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
 github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
+github.com/infisical/go-sdk v0.5.100 h1:XgaMSnd3nEqbQb6o1OpHRiLEvq/uiX+EI3ZdZWYFjUA=
+github.com/infisical/go-sdk v0.5.100/go.mod h1:j2D2a5WPNdKXDfHO+3y/TNyLWh5Aq9QYS7EcGI96LZI=
 github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
 github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
 github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
@@ -478,6 +484,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
 golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
 golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
@@ -493,6 +501,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
 golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
 golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
@@ -514,6 +524,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
 golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -521,6 +533,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
 golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
 golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -531,8 +545,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
 golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
 golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

+ 31 - 0
e2e/k8s/infisical.values.yaml

@@ -0,0 +1,31 @@
+# Values for the bundled Infisical standalone chart used by the e2e suite.
+# Postgres and Redis run in-cluster (chart subcharts); ingress is disabled
+# because the suite reaches the API through a port-forward and ESO reaches it
+# through the in-cluster Service. ENCRYPTION_KEY / AUTH_SECRET / SITE_URL are
+# supplied at runtime by the addon via the "infisical-secrets" Secret.
+infisical:
+  # Pin a predictable Service / Deployment name so the addon can port-forward
+  # and ESO can target it by DNS without templating the release name.
+  fullnameOverride: "infisical-backend"
+  replicaCount: 1
+  autoBootstrap:
+    enabled: false
+  image:
+    tag: "v0.158.0"
+  kubeSecretRef: "infisical-secrets"
+  resources:
+    requests:
+      cpu: 200m
+    limits:
+      memory: 1000Mi
+
+ingress:
+  enabled: false
+  nginx:
+    enabled: false
+
+postgresql:
+  enabled: true
+
+redis:
+  enabled: true

+ 1 - 0
e2e/suites/provider/cases/import.go

@@ -26,6 +26,7 @@ import (
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/delinea"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/fake"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/gcp"
+	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/infisical"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/kubernetes"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/openbao"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/scaleway"

+ 89 - 0
e2e/suites/provider/cases/infisical/infisical.go

@@ -0,0 +1,89 @@
+/*
+Copyright © The ESO 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
+
+    https://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 infisical
+
+import (
+	//nolint
+	. "github.com/onsi/ginkgo/v2"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	"github.com/external-secrets/external-secrets-e2e/framework/addon"
+	"github.com/external-secrets/external-secrets-e2e/suites/provider/cases/common"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+const (
+	withUniversalAuth        = "with universal auth"
+	withUniversalAuthCluster = "with universal auth and cluster store"
+)
+
+// The Infisical provider is read-only, so the suite seeds secrets through the
+// SDK and exercises the read paths only. PushSecret cases are out of scope.
+// FindByTag is excluded because the provider does not implement tag lookup
+// (it returns "find by tags not supported"), and FindByNameWithPath is
+// excluded because the provider matches ref.Path as a prefix of the absolute
+// Infisical secret path, which a bare namespace name never satisfies.
+// DeletionPolicyDelete is excluded because the provider returns the raw API
+// error on a missing secret rather than esv1.NoSecretErr, so ESO never
+// observes the upstream deletion that the policy keys off.
+var _ = Describe("[infisical]", Label("infisical"), Ordered, func() {
+	f := framework.New("infisical")
+	infisical := addon.NewInfisical()
+	prov := newInfisicalProvider(f, infisical)
+
+	BeforeAll(func() {
+		addon.InstallGlobalAddon(infisical)
+	})
+
+	DescribeTable("sync secrets",
+		framework.TableFuncWithExternalSecret(f, prov),
+		framework.Compose(withUniversalAuth, f, common.SimpleDataSync, useUniversalAuth(prov)),
+		framework.Compose(withUniversalAuth, f, common.SyncWithoutTargetName, useUniversalAuth(prov)),
+		framework.Compose(withUniversalAuth, f, common.JSONDataWithProperty, useUniversalAuth(prov)),
+		framework.Compose(withUniversalAuth, f, common.JSONDataWithoutTargetName, useUniversalAuth(prov)),
+		framework.Compose(withUniversalAuth, f, common.JSONDataWithTemplate, useUniversalAuth(prov)),
+		framework.Compose(withUniversalAuth, f, common.JSONDataWithTemplateFromLiteral, useUniversalAuth(prov)),
+		framework.Compose(withUniversalAuth, f, common.TemplateFromConfigmaps, useUniversalAuth(prov)),
+		framework.Compose(withUniversalAuth, f, common.JSONDataFromSync, useUniversalAuth(prov)),
+		framework.Compose(withUniversalAuth, f, common.JSONDataFromRewrite, useUniversalAuth(prov)),
+		framework.Compose(withUniversalAuth, f, common.NestedJSONWithGJSON, useUniversalAuth(prov)),
+		framework.Compose(withUniversalAuth, f, common.FindByName, useUniversalAuth(prov)),
+		framework.Compose(withUniversalAuth, f, common.FindByNameAndRewrite, useUniversalAuth(prov)),
+		framework.Compose(withUniversalAuth, f, common.DataPropertyDockerconfigJSON, useUniversalAuth(prov)),
+		framework.Compose(withUniversalAuth, f, common.SSHKeySync, useUniversalAuth(prov)),
+		framework.Compose(withUniversalAuth, f, common.SSHKeySyncDataProperty, useUniversalAuth(prov)),
+		framework.Compose(withUniversalAuth, f, common.DecodingPolicySync, useUniversalAuth(prov)),
+		framework.Compose(withUniversalAuth, f, common.StatusNotUpdatedAfterSuccessfulSync, useUniversalAuth(prov)),
+		// one case through a ClusterSecretStore to cover the cluster-scoped path
+		framework.Compose(withUniversalAuthCluster, f, common.JSONDataFromSync, useUniversalAuthClusterStore(prov)),
+	)
+})
+
+func useUniversalAuth(prov *infisicalProvider) func(*framework.TestCase) {
+	return func(tc *framework.TestCase) {
+		prov.CreateUniversalAuthStore()
+		tc.ExternalSecret.Spec.SecretStoreRef.Name = tc.Framework.Namespace.Name
+	}
+}
+
+func useUniversalAuthClusterStore(prov *infisicalProvider) func(*framework.TestCase) {
+	return func(tc *framework.TestCase) {
+		prov.CreateUniversalAuthClusterStore()
+		tc.ExternalSecret.Spec.SecretStoreRef.Name = clusterStoreName(tc.Framework)
+		tc.ExternalSecret.Spec.SecretStoreRef.Kind = esv1.ClusterSecretStoreKind
+	}
+}

+ 222 - 0
e2e/suites/provider/cases/infisical/provider.go

@@ -0,0 +1,222 @@
+/*
+Copyright © The ESO 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
+
+    https://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 infisical
+
+import (
+	"context"
+	"path"
+	"strings"
+	"sync"
+	"time"
+
+	infisicalSdk "github.com/infisical/go-sdk"
+
+	//nolint
+	. "github.com/onsi/ginkgo/v2"
+	//nolint
+	. "github.com/onsi/gomega"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	"github.com/external-secrets/external-secrets-e2e/framework/addon"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+)
+
+const (
+	credentialsSecretName = "infisical-credentials"
+	clientIDKey           = "clientId"
+	clientSecretKey       = "clientSecret"
+	// scopePath is the secret path the store is scoped to. All e2e keys live
+	// directly under it; the provider resolves bare keys against this path.
+	scopePath = "/"
+)
+
+type infisicalProvider struct {
+	addon     *addon.Infisical
+	framework *framework.Framework
+
+	// created tracks the keys seeded for the current spec so DeleteSecret is
+	// idempotent: the framework's deferred cleanup may delete a key that a
+	// test (e.g. DeletionPolicyDelete) already removed, and Infisical errors
+	// on deleting a missing secret.
+	mu      sync.Mutex
+	created map[string]bool
+}
+
+func newInfisicalProvider(f *framework.Framework, a *addon.Infisical) *infisicalProvider {
+	prov := &infisicalProvider{
+		addon:     a,
+		framework: f,
+	}
+	BeforeEach(func() {
+		prov.mu.Lock()
+		prov.created = map[string]bool{}
+		prov.mu.Unlock()
+	})
+	return prov
+}
+
+// CreateSecret seeds a secret in Infisical. The provider is read-only, so the
+// suite writes through the SDK rather than via PushSecret.
+func (s *infisicalProvider) CreateSecret(key string, val framework.SecretEntry) {
+	secretPath, name := secretAddress(scopePath, key)
+	_, err := s.addon.SDKClient.Secrets().Create(infisicalSdk.CreateSecretOptions{
+		ProjectID:             s.addon.ProjectID,
+		Environment:           s.addon.EnvironmentSlug,
+		SecretPath:            secretPath,
+		SecretKey:             name,
+		SecretValue:           val.Value,
+		SkipMultiLineEncoding: true,
+	})
+	Expect(err).ToNot(HaveOccurred())
+
+	s.mu.Lock()
+	s.created[key] = true
+	s.mu.Unlock()
+}
+
+func (s *infisicalProvider) DeleteSecret(key string) {
+	s.mu.Lock()
+	seeded := s.created[key]
+	delete(s.created, key)
+	s.mu.Unlock()
+	if !seeded {
+		return
+	}
+
+	secretPath, name := secretAddress(scopePath, key)
+	_, err := s.addon.SDKClient.Secrets().Delete(infisicalSdk.DeleteSecretOptions{
+		ProjectID:   s.addon.ProjectID,
+		Environment: s.addon.EnvironmentSlug,
+		SecretPath:  secretPath,
+		SecretKey:   name,
+	})
+	Expect(err).ToNot(HaveOccurred())
+}
+
+// secretAddress mirrors the provider's key resolution so the seeded path
+// matches where the provider looks the secret up.
+//   - no slash:        ("foo", "/scope")      -> ("/scope", "foo")
+//   - leading slash:   ("/a/b/foo", "/scope") -> ("/a/b", "foo")
+//   - relative path:   ("sub/foo", "/scope")  -> ("/scope/sub", "foo")
+func secretAddress(defaultPath, key string) (string, string) {
+	if !strings.Contains(key, "/") {
+		return defaultPath, key
+	}
+	lastIndex := strings.LastIndex(key, "/")
+	folder, name := key[:lastIndex], key[lastIndex+1:]
+	if strings.HasPrefix(key, "/") {
+		return folder, name
+	}
+	return path.Join(defaultPath, folder), name
+}
+
+func (s *infisicalProvider) credentialsSecret(ns string) *v1.Secret {
+	return &v1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      credentialsSecretName,
+			Namespace: ns,
+		},
+		Data: map[string][]byte{
+			clientIDKey:     []byte(s.addon.ClientID),
+			clientSecretKey: []byte(s.addon.ClientSecret),
+		},
+	}
+}
+
+func (s *infisicalProvider) infisicalProviderSpec(credsNamespace *string) *esv1.InfisicalProvider {
+	return &esv1.InfisicalProvider{
+		HostAPI: s.addon.HostAPI,
+		Auth: esv1.InfisicalAuth{
+			UniversalAuthCredentials: &esv1.UniversalAuthCredentials{
+				ClientID: esmeta.SecretKeySelector{
+					Name:      credentialsSecretName,
+					Key:       clientIDKey,
+					Namespace: credsNamespace,
+				},
+				ClientSecret: esmeta.SecretKeySelector{
+					Name:      credentialsSecretName,
+					Key:       clientSecretKey,
+					Namespace: credsNamespace,
+				},
+			},
+		},
+		SecretsScope: esv1.MachineIdentityScopeInWorkspace{
+			ProjectSlug:            s.addon.ProjectSlug,
+			EnvironmentSlug:        s.addon.EnvironmentSlug,
+			SecretsPath:            scopePath,
+			ExpandSecretReferences: true,
+		},
+	}
+}
+
+// CreateUniversalAuthStore creates a namespaced SecretStore authenticated with
+// the Universal Auth machine identity.
+func (s *infisicalProvider) CreateUniversalAuthStore() {
+	ns := s.framework.Namespace.Name
+	err := s.framework.CRClient.Create(GinkgoT().Context(), s.credentialsSecret(ns))
+	Expect(err).ToNot(HaveOccurred())
+
+	store := &esv1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      ns,
+			Namespace: ns,
+		},
+		Spec: esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{
+				Infisical: s.infisicalProviderSpec(nil),
+			},
+		},
+	}
+	err = s.framework.CRClient.Create(GinkgoT().Context(), store)
+	Expect(err).ToNot(HaveOccurred())
+}
+
+// CreateUniversalAuthClusterStore creates a ClusterSecretStore that references
+// the credentials Secret in the test namespace.
+func (s *infisicalProvider) CreateUniversalAuthClusterStore() {
+	ns := s.framework.Namespace.Name
+	err := s.framework.CRClient.Create(GinkgoT().Context(), s.credentialsSecret(ns))
+	Expect(err).ToNot(HaveOccurred())
+
+	store := &esv1.ClusterSecretStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: clusterStoreName(s.framework),
+		},
+		Spec: esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{
+				Infisical: s.infisicalProviderSpec(&ns),
+			},
+		},
+	}
+	err = s.framework.CRClient.Create(GinkgoT().Context(), store)
+	Expect(err).ToNot(HaveOccurred())
+
+	DeferCleanup(func() {
+		// Cannot use the ginkgo context inside DeferCleanup, it would register
+		// another cleanup. Use a plain context with a short timeout instead.
+		ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
+		defer cancel()
+		_ = s.framework.CRClient.Delete(ctx, store)
+	})
+}
+
+func clusterStoreName(f *framework.Framework) string {
+	return "infisical-" + f.Namespace.Name
+}