Sfoglia il codice sorgente

test: add shared provider v2 e2e cases

Moritz Johner 2 mesi fa
parent
commit
44c08f1104

+ 252 - 0
e2e/suites/provider/cases/common/clusterprovider.go

@@ -0,0 +1,252 @@
+/*
+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 common
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+type ClusterProviderConfig struct {
+	Name       string
+	AuthScope  esv1.AuthenticationScope
+	Conditions []esv1.ClusterSecretStoreCondition
+}
+
+type ClusterProviderExternalSecretHarness struct {
+	Prepare func(tc *framework.TestCase, cfg ClusterProviderConfig) *ClusterProviderExternalSecretRuntime
+}
+
+type ClusterProviderExternalSecretRuntime struct {
+	ClusterProviderName string
+	Provider            framework.SecretStoreProvider
+	BreakAuth           func()
+	RepairAuth          func()
+}
+
+func (r *ClusterProviderExternalSecretRuntime) SupportsAuthLifecycle() bool {
+	return r != nil && r.BreakAuth != nil && r.RepairAuth != nil
+}
+
+func ClusterProviderManifestNamespace(f *framework.Framework, harness ClusterProviderExternalSecretHarness) (string, func(*framework.TestCase)) {
+	return clusterProviderSyncCase(f, harness, "manifest", "manifest-value", esv1.AuthenticationScopeManifestNamespace)
+}
+
+func ClusterProviderProviderNamespace(f *framework.Framework, harness ClusterProviderExternalSecretHarness) (string, func(*framework.TestCase)) {
+	return clusterProviderSyncCase(f, harness, "provider", "provider-value", esv1.AuthenticationScopeProviderNamespace)
+}
+
+func ClusterProviderManifestNamespaceRecovery(f *framework.Framework, harness ClusterProviderExternalSecretHarness) (string, func(*framework.TestCase)) {
+	return clusterProviderRecoveryCase(f, harness, "manifest-recovery", "manifest-recovered", esv1.AuthenticationScopeManifestNamespace)
+}
+
+func ClusterProviderProviderNamespaceRecovery(f *framework.Framework, harness ClusterProviderExternalSecretHarness) (string, func(*framework.TestCase)) {
+	return clusterProviderRecoveryCase(f, harness, "provider-recovery", "provider-recovered", esv1.AuthenticationScopeProviderNamespace)
+}
+
+func ClusterProviderDeniedByConditions(f *framework.Framework, harness ClusterProviderExternalSecretHarness) (string, func(*framework.TestCase)) {
+	return "[common] should deny workload namespaces that do not match ClusterProvider conditions", func(tc *framework.TestCase) {
+		targetSecretName := "denied-target"
+		remoteSecretName := "denied-source"
+		expectedMessage := "should-not-sync"
+
+		tc.ExpectedSecret = nil
+		tc.ExternalSecret.ObjectMeta.Name = "denied-external-secret"
+		tc.ExternalSecret.Spec.Target.Name = targetSecretName
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{{
+			SecretKey: "value",
+			RemoteRef: esv1.ExternalSecretDataRemoteRef{
+				Key:      remoteSecretName,
+				Property: "value",
+			},
+		}}
+		tc.Secrets = map[string]framework.SecretEntry{
+			remoteSecretName: {Value: jsonSecretValue(expectedMessage)},
+		}
+
+		var runtime *ClusterProviderExternalSecretRuntime
+		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			runtime = harness.Prepare(tc, ClusterProviderConfig{
+				Name:      "deny",
+				AuthScope: esv1.AuthenticationScopeManifestNamespace,
+				Conditions: []esv1.ClusterSecretStoreCondition{{
+					Namespaces: []string{"not-" + f.Namespace.Name},
+				}},
+			})
+			applyClusterProviderExternalSecret(tc, runtime)
+		}
+		tc.AfterSync = func(_ framework.SecretStoreProvider, _ *corev1.Secret) {
+			waitForExternalSecretStatus(tc.Framework, tc.ExternalSecret.Namespace, tc.ExternalSecret.Name, corev1.ConditionFalse)
+			expectNoSecretInNamespace(tc.Framework, tc.ExternalSecret.Namespace, targetSecretName)
+			expectEventMessage(
+				tc.Framework,
+				tc.ExternalSecret.Namespace,
+				tc.ExternalSecret.Name,
+				"ExternalSecret",
+				fmt.Sprintf("using ClusterProviderStore %q is not allowed from namespace %q: denied by spec.conditions", runtime.ClusterProviderName, f.Namespace.Name),
+			)
+		}
+	}
+}
+
+func clusterProviderSyncCase(f *framework.Framework, harness ClusterProviderExternalSecretHarness, name, expectedValue string, authScope esv1.AuthenticationScope) (string, func(*framework.TestCase)) {
+	return fmt.Sprintf("[common] should use %s auth with ClusterProvider", authScope), func(tc *framework.TestCase) {
+		targetSecretName := fmt.Sprintf("%s-target", name)
+		remoteSecretName := fmt.Sprintf("%s-source", name)
+
+		tc.ExternalSecret.ObjectMeta.Name = fmt.Sprintf("%s-external-secret", name)
+		tc.ExternalSecret.Spec.Target.Name = targetSecretName
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{{
+			SecretKey: "value",
+			RemoteRef: esv1.ExternalSecretDataRemoteRef{
+				Key:      remoteSecretName,
+				Property: "value",
+			},
+		}}
+		tc.Secrets = map[string]framework.SecretEntry{
+			remoteSecretName: {Value: jsonSecretValue(expectedValue)},
+		}
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{
+				"value": []byte(expectedValue),
+			},
+		}
+		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			runtime := harness.Prepare(tc, ClusterProviderConfig{
+				Name:      name,
+				AuthScope: authScope,
+			})
+			applyClusterProviderExternalSecret(tc, runtime)
+		}
+	}
+}
+
+func clusterProviderRecoveryCase(f *framework.Framework, harness ClusterProviderExternalSecretHarness, name, expectedValue string, authScope esv1.AuthenticationScope) (string, func(*framework.TestCase)) {
+	return fmt.Sprintf("[common] should recover after repairing ClusterProvider auth with %s scope", authScope), func(tc *framework.TestCase) {
+		targetSecretName := fmt.Sprintf("%s-target", name)
+		remoteSecretName := fmt.Sprintf("%s-source", name)
+
+		tc.ExpectedSecret = nil
+		tc.ExternalSecret.ObjectMeta.Name = fmt.Sprintf("%s-external-secret", name)
+		tc.ExternalSecret.Spec.RefreshInterval = &metav1.Duration{Duration: time.Hour}
+		tc.ExternalSecret.Spec.Target.Name = targetSecretName
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{{
+			SecretKey: "value",
+			RemoteRef: esv1.ExternalSecretDataRemoteRef{
+				Key:      remoteSecretName,
+				Property: "value",
+			},
+		}}
+		tc.Secrets = map[string]framework.SecretEntry{
+			remoteSecretName: {Value: jsonSecretValue(expectedValue)},
+		}
+
+		var runtime *ClusterProviderExternalSecretRuntime
+		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			runtime = harness.Prepare(tc, ClusterProviderConfig{
+				Name:      name,
+				AuthScope: authScope,
+			})
+			Expect(runtime).NotTo(BeNil(), "cluster provider harness returned nil runtime")
+			if !runtime.SupportsAuthLifecycle() {
+				Skip(fmt.Sprintf("provider %q does not support auth lifecycle recovery hooks", runtime.ClusterProviderName))
+			}
+			applyClusterProviderExternalSecret(tc, runtime)
+			runtime.BreakAuth()
+		}
+		tc.AfterSync = func(_ framework.SecretStoreProvider, _ *corev1.Secret) {
+			waitForExternalSecretStatus(tc.Framework, tc.ExternalSecret.Namespace, tc.ExternalSecret.Name, corev1.ConditionFalse)
+			expectNoSecretInNamespace(tc.Framework, tc.ExternalSecret.Namespace, targetSecretName)
+			runtime.RepairAuth()
+			waitForSecretData(tc.Framework, tc.ExternalSecret.Namespace, targetSecretName, map[string][]byte{
+				"value": []byte(expectedValue),
+			}, 30*time.Second)
+		}
+	}
+}
+
+func applyClusterProviderExternalSecret(tc *framework.TestCase, runtime *ClusterProviderExternalSecretRuntime) {
+	tc.ProviderOverride = runtime.Provider
+	tc.ExternalSecret.Spec.SecretStoreRef.Name = runtime.ClusterProviderName
+	tc.ExternalSecret.Spec.SecretStoreRef.Kind = esv1.ClusterProviderStoreKindStr
+}
+
+func waitForExternalSecretStatus(f *framework.Framework, namespace, name string, status corev1.ConditionStatus) {
+	Eventually(func(g Gomega) {
+		var externalSecret esv1.ExternalSecret
+		g.Expect(f.CRClient.Get(context.Background(), types.NamespacedName{
+			Name:      name,
+			Namespace: namespace,
+		}, &externalSecret)).To(Succeed())
+		condition := esv1.GetExternalSecretCondition(externalSecret.Status, esv1.ExternalSecretReady)
+		g.Expect(condition).NotTo(BeNil())
+		g.Expect(condition.Status).To(Equal(status))
+	}, time.Minute, 5*time.Second).Should(Succeed())
+}
+
+func waitForSecretData(f *framework.Framework, namespace, name string, expected map[string][]byte, timeout time.Duration) {
+	Eventually(func(g Gomega) {
+		var syncedSecret corev1.Secret
+		g.Expect(f.CRClient.Get(context.Background(), types.NamespacedName{
+			Name:      name,
+			Namespace: namespace,
+		}, &syncedSecret)).To(Succeed())
+		g.Expect(syncedSecret.Type).To(Equal(corev1.SecretTypeOpaque))
+		g.Expect(syncedSecret.Data).To(Equal(expected))
+	}, timeout, 5*time.Second).Should(Succeed())
+}
+
+func expectNoSecretInNamespace(f *framework.Framework, namespace, name string) {
+	Consistently(func() bool {
+		var secret corev1.Secret
+		err := f.CRClient.Get(context.Background(), types.NamespacedName{Name: name, Namespace: namespace}, &secret)
+		return apierrors.IsNotFound(err)
+	}, 10*time.Second, 5*time.Second).Should(BeTrue())
+}
+
+func expectEventMessage(f *framework.Framework, namespace, objectName, objectKind, expectedMessage string) {
+	Eventually(func() string {
+		events, err := f.KubeClientSet.CoreV1().Events(namespace).List(context.Background(), metav1.ListOptions{
+			FieldSelector: "involvedObject.name=" + objectName + ",involvedObject.kind=" + objectKind,
+		})
+		Expect(err).NotTo(HaveOccurred())
+		messages := make([]string, 0, len(events.Items))
+		for _, event := range events.Items {
+			if event.Message != "" {
+				messages = append(messages, event.Message)
+			}
+		}
+		return fmt.Sprintf("%v", messages)
+	}, time.Minute, 5*time.Second).Should(ContainSubstring(expectedMessage))
+}
+
+func jsonSecretValue(value string) string {
+	return fmt.Sprintf(`{"value":%q}`, value)
+}

+ 2 - 1
e2e/suites/provider/cases/common/common.go

@@ -19,7 +19,6 @@ import (
 	"fmt"
 	"time"
 
-	. "github.com/onsi/ginkgo/v2"
 	"github.com/onsi/gomega"
 	v1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/errors"
@@ -28,6 +27,8 @@ import (
 
 	"github.com/external-secrets/external-secrets-e2e/framework"
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+
+	. "github.com/onsi/ginkgo/v2"
 )
 
 const (

+ 79 - 0
e2e/suites/provider/cases/common/fake_provider.go

@@ -0,0 +1,79 @@
+/*
+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 common
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+)
+
+func FakeProviderSync(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[fake] should sync a namespaced secret", func(tc *framework.TestCase) {
+		_, prepare := NamespacedProviderSync(f, NamespacedProviderSyncConfig{
+			Description:        "[fake] should sync a namespaced secret",
+			ExternalSecretName: "fake-sync-es",
+			TargetSecretName:   "fake-sync-target",
+			RemoteKey:          fmt.Sprintf("fake-sync-%s", f.Namespace.Name),
+			RemoteSecretValue:  `{"value":"fake-sync-value"}`,
+			RemoteProperty:     "value",
+			SecretKey:          "value",
+			ExpectedValue:      "fake-sync-value",
+		})
+		prepare(tc)
+	}
+}
+
+func FakeProviderRefresh(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[fake] should refresh after the provider data changes", func(tc *framework.TestCase) {
+		_, prepare := NamespacedProviderRefresh(f, NamespacedProviderRefreshConfig{
+			Description:         "[fake] should refresh after the provider data changes",
+			ExternalSecretName:  "fake-refresh-es",
+			TargetSecretName:    "fake-refresh-target",
+			RemoteKey:           fmt.Sprintf("fake-refresh-%s", f.Namespace.Name),
+			InitialSecretValue:  `{"value":"fake-initial-value"}`,
+			UpdatedSecretValue:  `{"value":"fake-updated-value"}`,
+			RemoteProperty:      "value",
+			SecretKey:           "value",
+			InitialExpectedData: "fake-initial-value",
+			UpdatedExpectedData: "fake-updated-value",
+			RefreshInterval:     10 * time.Second,
+			WaitTimeout:         30 * time.Second,
+		})
+		prepare(tc)
+	}
+}
+
+func FakeProviderFind(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[fake] should sync dataFrom.find matches", func(tc *framework.TestCase) {
+		_, prepare := NamespacedProviderFind(f, NamespacedProviderFindConfig{
+			Description:        "[fake] should sync dataFrom.find matches",
+			ExternalSecretName: "fake-find-es",
+			TargetSecretName:   "fake-find-target",
+			MatchRegExp:        fmt.Sprintf("fake-find-%s-(one|two)", f.Namespace.Name),
+			MatchingSecrets: map[string]string{
+				fmt.Sprintf("fake-find-%s-one", f.Namespace.Name): `{"value":"fake-find-one"}`,
+				fmt.Sprintf("fake-find-%s-two", f.Namespace.Name): `{"value":"fake-find-two"}`,
+			},
+			IgnoredSecrets: map[string]string{
+				fmt.Sprintf("fake-find-ignore-%s", f.Namespace.Name): `{"value":"fake-ignore"}`,
+			},
+		})
+		prepare(tc)
+	}
+}

+ 45 - 0
e2e/suites/provider/cases/common/fake_provider_test.go

@@ -0,0 +1,45 @@
+/*
+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 common
+
+import (
+	"testing"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+)
+
+func TestFakeProviderHelpersDoNotRequireNamespaceAtConstruction(t *testing.T) {
+	t.Parallel()
+
+	f := &framework.Framework{}
+
+	assertDoesNotPanic(t, func() { FakeProviderSync(f) })
+	assertDoesNotPanic(t, func() { FakeProviderRefresh(f) })
+	assertDoesNotPanic(t, func() { FakeProviderFind(f) })
+}
+
+func assertDoesNotPanic(t *testing.T, fn func()) {
+	t.Helper()
+
+	defer func() {
+		if r := recover(); r != nil {
+			t.Fatalf("unexpected panic: %v", r)
+		}
+	}()
+
+	fn()
+}

+ 181 - 0
e2e/suites/provider/cases/common/namespaced_provider.go

@@ -0,0 +1,181 @@
+/*
+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 common
+
+import (
+	"context"
+	"time"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+
+	. "github.com/onsi/gomega"
+)
+
+type NamespacedProviderSyncConfig struct {
+	Description        string
+	ExternalSecretName string
+	TargetSecretName   string
+	RemoteKey          string
+	RemoteSecretValue  string
+	RemoteProperty     string
+	SecretKey          string
+	ExpectedValue      string
+}
+
+func NamespacedProviderSync(_ *framework.Framework, cfg NamespacedProviderSyncConfig) (string, func(*framework.TestCase)) {
+	return cfg.Description, func(tc *framework.TestCase) {
+		tc.ExternalSecret.ObjectMeta.Name = cfg.ExternalSecretName
+		tc.ExternalSecret.Spec.Target.Name = cfg.TargetSecretName
+		tc.Secrets = map[string]framework.SecretEntry{
+			cfg.RemoteKey: {Value: cfg.RemoteSecretValue},
+		}
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{
+				cfg.SecretKey: []byte(cfg.ExpectedValue),
+			},
+		}
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{{
+			SecretKey: cfg.SecretKey,
+			RemoteRef: esv1.ExternalSecretDataRemoteRef{
+				Key:      cfg.RemoteKey,
+				Property: cfg.RemoteProperty,
+			},
+		}}
+	}
+}
+
+type NamespacedProviderRefreshConfig struct {
+	Description         string
+	ExternalSecretName  string
+	TargetSecretName    string
+	RemoteKey           string
+	InitialSecretValue  string
+	UpdatedSecretValue  string
+	RemoteProperty      string
+	SecretKey           string
+	InitialExpectedData string
+	UpdatedExpectedData string
+	RefreshInterval     time.Duration
+	WaitTimeout         time.Duration
+	UpdateRemoteSecret  func(tc *framework.TestCase, prov framework.SecretStoreProvider)
+}
+
+func NamespacedProviderRefresh(_ *framework.Framework, cfg NamespacedProviderRefreshConfig) (string, func(*framework.TestCase)) {
+	return cfg.Description, func(tc *framework.TestCase) {
+		refreshInterval := cfg.RefreshInterval
+		if refreshInterval <= 0 {
+			refreshInterval = 10 * time.Second
+		}
+
+		waitTimeout := cfg.WaitTimeout
+		if waitTimeout == 0 {
+			waitTimeout = 30 * time.Second
+		}
+
+		tc.ExternalSecret.ObjectMeta.Name = cfg.ExternalSecretName
+		tc.ExternalSecret.Spec.Target.Name = cfg.TargetSecretName
+		tc.ExternalSecret.Spec.RefreshInterval = &metav1.Duration{Duration: refreshInterval}
+		tc.Secrets = map[string]framework.SecretEntry{
+			cfg.RemoteKey: {Value: cfg.InitialSecretValue},
+		}
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{
+				cfg.SecretKey: []byte(cfg.InitialExpectedData),
+			},
+		}
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{{
+			SecretKey: cfg.SecretKey,
+			RemoteRef: esv1.ExternalSecretDataRemoteRef{
+				Key:      cfg.RemoteKey,
+				Property: cfg.RemoteProperty,
+			},
+		}}
+		tc.AfterSync = func(prov framework.SecretStoreProvider, _ *corev1.Secret) {
+			if cfg.UpdateRemoteSecret != nil {
+				cfg.UpdateRemoteSecret(tc, prov)
+			} else {
+				prov.DeleteSecret(cfg.RemoteKey)
+				prov.CreateSecret(cfg.RemoteKey, framework.SecretEntry{
+					Value: cfg.UpdatedSecretValue,
+				})
+			}
+			waitForNamespacedProviderSecretData(tc.Framework, tc.ExternalSecret.Namespace, tc.ExternalSecret.Spec.Target.Name, map[string][]byte{
+				cfg.SecretKey: []byte(cfg.UpdatedExpectedData),
+			}, waitTimeout)
+		}
+	}
+}
+
+type NamespacedProviderFindConfig struct {
+	Description        string
+	ExternalSecretName string
+	TargetSecretName   string
+	MatchRegExp        string
+	MatchingSecrets    map[string]string
+	IgnoredSecrets     map[string]string
+}
+
+func NamespacedProviderFind(_ *framework.Framework, cfg NamespacedProviderFindConfig) (string, func(*framework.TestCase)) {
+	return cfg.Description, func(tc *framework.TestCase) {
+		secrets := make(map[string]framework.SecretEntry, len(cfg.MatchingSecrets)+len(cfg.IgnoredSecrets))
+		for key, value := range cfg.MatchingSecrets {
+			secrets[key] = framework.SecretEntry{Value: value}
+		}
+		for key, value := range cfg.IgnoredSecrets {
+			secrets[key] = framework.SecretEntry{Value: value}
+		}
+
+		expectedData := make(map[string][]byte, len(cfg.MatchingSecrets))
+		for key, value := range cfg.MatchingSecrets {
+			expectedData[key] = []byte(value)
+		}
+
+		tc.ExternalSecret.ObjectMeta.Name = cfg.ExternalSecretName
+		tc.ExternalSecret.Spec.Target.Name = cfg.TargetSecretName
+		tc.Secrets = secrets
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: expectedData,
+		}
+		tc.ExternalSecret.Spec.DataFrom = []esv1.ExternalSecretDataFromRemoteRef{{
+			Find: &esv1.ExternalSecretFind{
+				Name: &esv1.FindName{
+					RegExp: cfg.MatchRegExp,
+				},
+			},
+		}}
+	}
+}
+
+func waitForNamespacedProviderSecretData(f *framework.Framework, namespace, name string, expected map[string][]byte, timeout time.Duration) {
+	Eventually(func(g Gomega) {
+		var syncedSecret corev1.Secret
+		g.Expect(f.CRClient.Get(context.Background(), types.NamespacedName{
+			Name:      name,
+			Namespace: namespace,
+		}, &syncedSecret)).To(Succeed())
+		g.Expect(syncedSecret.Type).To(Equal(corev1.SecretTypeOpaque))
+		g.Expect(syncedSecret.Data).To(Equal(expected))
+	}, timeout, 5*time.Second).Should(Succeed())
+}

+ 393 - 0
e2e/suites/provider/cases/common/operational_v2.go

@@ -0,0 +1,393 @@
+/*
+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 common
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	frameworkv2 "github.com/external-secrets/external-secrets-e2e/framework/v2"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+const (
+	operationalPollInterval = 5 * time.Second
+	operationalTimeout      = 3 * time.Minute
+)
+
+type OperationalRuntime struct {
+	Provider               framework.SecretStoreProvider
+	ProviderRef            esv1.SecretStoreRef
+	DefaultRemoteNamespace string
+	WaitForRemoteSecret    func(namespace, name, key, expectedValue string)
+	ExpectNoRemoteSecret   func(namespace, name string)
+	MakeUnavailable        func()
+	RestoreAvailability    func()
+	RestartBackend         func()
+}
+
+func (r *OperationalRuntime) SupportsDisruptionLifecycle() bool {
+	return r != nil && r.MakeUnavailable != nil && r.RestoreAvailability != nil
+}
+
+func (r *OperationalRuntime) SupportsRestart() bool {
+	return r != nil && r.RestartBackend != nil
+}
+
+type OperationalExternalSecretHarness struct {
+	PrepareNamespaced func(tc *framework.TestCase) *OperationalRuntime
+	PrepareCluster    func(tc *framework.TestCase, cfg ClusterProviderConfig) *OperationalRuntime
+}
+
+type OperationalPushSecretHarness struct {
+	PrepareNamespaced func(tc *framework.TestCase) *OperationalRuntime
+	PrepareCluster    func(tc *framework.TestCase, cfg ClusterProviderConfig) *OperationalRuntime
+}
+
+func NamespacedProviderUnavailable(f *framework.Framework, harness OperationalExternalSecretHarness, remoteKey, expectedValue string) (string, func(*framework.TestCase)) {
+	return "[common] should surface namespaced Provider unavailability and recover after backend restoration", func(tc *framework.TestCase) {
+		tc.ExternalSecret.ObjectMeta.Name = "operational-unavailable-es"
+		tc.ExternalSecret.Spec.Target.Name = "operational-unavailable-target"
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{{
+			SecretKey: "value",
+			RemoteRef: esv1.ExternalSecretDataRemoteRef{
+				Key:      remoteKey,
+				Property: "value",
+			},
+		}}
+		tc.Secrets = map[string]framework.SecretEntry{
+			remoteKey: {Value: jsonSecretValue(expectedValue)},
+		}
+		tc.ExpectedSecret = opaqueValueSecret(expectedValue)
+
+		var runtime *OperationalRuntime
+		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			runtime = harness.PrepareNamespaced(tc)
+			applyOperationalExternalSecret(tc, runtime)
+		}
+		tc.AfterSync = func(_ framework.SecretStoreProvider, _ *corev1.Secret) {
+			skipIfOperationalRuntimeMissingDisruptionLifecycle(runtime)
+			DeferCleanup(func() {
+				runtime.RestoreAvailability()
+				waitForProviderRefCondition(tc.Framework, tc.ExternalSecret.Namespace, runtime.ProviderRef, metav1.ConditionTrue)
+			})
+			runtime.MakeUnavailable()
+			waitForExternalSecretStatus(tc.Framework, tc.ExternalSecret.Namespace, tc.ExternalSecret.Name, corev1.ConditionFalse)
+
+			runtime.RestoreAvailability()
+			waitForProviderRefCondition(tc.Framework, tc.ExternalSecret.Namespace, runtime.ProviderRef, metav1.ConditionTrue)
+			waitForExternalSecretStatus(tc.Framework, tc.ExternalSecret.Namespace, tc.ExternalSecret.Name, corev1.ConditionTrue)
+			waitForSecretData(tc.Framework, tc.ExternalSecret.Namespace, tc.ExternalSecret.Spec.Target.Name, map[string][]byte{
+				"value": []byte(expectedValue),
+			}, operationalTimeout)
+		}
+	}
+}
+
+func NamespacedProviderRestart(f *framework.Framework, harness OperationalExternalSecretHarness, remoteKey, expectedValue string) (string, func(*framework.TestCase)) {
+	return "[common] should recover namespaced Provider reads after backend restart", func(tc *framework.TestCase) {
+		tc.ExternalSecret.ObjectMeta.Name = "operational-restart-es"
+		tc.ExternalSecret.Spec.Target.Name = "operational-restart-target"
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{{
+			SecretKey: "value",
+			RemoteRef: esv1.ExternalSecretDataRemoteRef{
+				Key:      remoteKey,
+				Property: "value",
+			},
+		}}
+		tc.Secrets = map[string]framework.SecretEntry{
+			remoteKey: {Value: jsonSecretValue("before-restart")},
+		}
+		tc.ExpectedSecret = opaqueValueSecret("before-restart")
+
+		var runtime *OperationalRuntime
+		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			runtime = harness.PrepareNamespaced(tc)
+			applyOperationalExternalSecret(tc, runtime)
+		}
+		tc.AfterSync = func(prov framework.SecretStoreProvider, _ *corev1.Secret) {
+			skipIfOperationalRuntimeMissingRestart(runtime)
+			runtime.RestartBackend()
+			waitForProviderRefCondition(tc.Framework, tc.ExternalSecret.Namespace, runtime.ProviderRef, metav1.ConditionTrue)
+
+			prov.DeleteSecret(remoteKey)
+			prov.CreateSecret(remoteKey, framework.SecretEntry{Value: jsonSecretValue(expectedValue)})
+
+			waitForExternalSecretStatus(tc.Framework, tc.ExternalSecret.Namespace, tc.ExternalSecret.Name, corev1.ConditionTrue)
+			waitForSecretData(tc.Framework, tc.ExternalSecret.Namespace, tc.ExternalSecret.Spec.Target.Name, map[string][]byte{
+				"value": []byte(expectedValue),
+			}, operationalTimeout)
+		}
+	}
+}
+
+func ClusterProviderUnavailable(f *framework.Framework, harness OperationalExternalSecretHarness, remoteKey, expectedValue string, authScope esv1.AuthenticationScope) (string, func(*framework.TestCase)) {
+	return fmt.Sprintf("[common] should surface ClusterProvider unavailability and recover with %s auth", authScope), func(tc *framework.TestCase) {
+		scopeSuffix := operationalScopeSuffix(authScope)
+		tc.ExternalSecret.ObjectMeta.Name = fmt.Sprintf("operational-cluster-unavailable-%s", scopeSuffix)
+		tc.ExternalSecret.Spec.Target.Name = fmt.Sprintf("operational-cluster-unavailable-%s-target", scopeSuffix)
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{{
+			SecretKey: "value",
+			RemoteRef: esv1.ExternalSecretDataRemoteRef{
+				Key:      remoteKey,
+				Property: "value",
+			},
+		}}
+		tc.Secrets = map[string]framework.SecretEntry{
+			remoteKey: {Value: jsonSecretValue(expectedValue)},
+		}
+		tc.ExpectedSecret = opaqueValueSecret(expectedValue)
+
+		var runtime *OperationalRuntime
+		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			runtime = harness.PrepareCluster(tc, ClusterProviderConfig{
+				Name:      "operational-unavailable",
+				AuthScope: authScope,
+			})
+			applyOperationalExternalSecret(tc, runtime)
+		}
+		tc.AfterSync = func(_ framework.SecretStoreProvider, _ *corev1.Secret) {
+			skipIfOperationalRuntimeMissingDisruptionLifecycle(runtime)
+			DeferCleanup(func() {
+				runtime.RestoreAvailability()
+				waitForProviderRefCondition(tc.Framework, tc.ExternalSecret.Namespace, runtime.ProviderRef, metav1.ConditionTrue)
+			})
+			runtime.MakeUnavailable()
+			waitForExternalSecretStatus(tc.Framework, tc.ExternalSecret.Namespace, tc.ExternalSecret.Name, corev1.ConditionFalse)
+
+			runtime.RestoreAvailability()
+			waitForProviderRefCondition(tc.Framework, tc.ExternalSecret.Namespace, runtime.ProviderRef, metav1.ConditionTrue)
+			waitForExternalSecretStatus(tc.Framework, tc.ExternalSecret.Namespace, tc.ExternalSecret.Name, corev1.ConditionTrue)
+			waitForSecretData(tc.Framework, tc.ExternalSecret.Namespace, tc.ExternalSecret.Spec.Target.Name, map[string][]byte{
+				"value": []byte(expectedValue),
+			}, operationalTimeout)
+		}
+	}
+}
+
+func ClusterProviderRestart(f *framework.Framework, harness OperationalExternalSecretHarness, remoteKey, expectedValue string, authScope esv1.AuthenticationScope) (string, func(*framework.TestCase)) {
+	return fmt.Sprintf("[common] should recover ClusterProvider reads after backend restart with %s auth", authScope), func(tc *framework.TestCase) {
+		scopeSuffix := operationalScopeSuffix(authScope)
+		tc.ExternalSecret.ObjectMeta.Name = fmt.Sprintf("operational-cluster-restart-%s", scopeSuffix)
+		tc.ExternalSecret.Spec.Target.Name = fmt.Sprintf("operational-cluster-restart-%s-target", scopeSuffix)
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{{
+			SecretKey: "value",
+			RemoteRef: esv1.ExternalSecretDataRemoteRef{
+				Key:      remoteKey,
+				Property: "value",
+			},
+		}}
+		tc.Secrets = map[string]framework.SecretEntry{
+			remoteKey: {Value: jsonSecretValue("before-restart")},
+		}
+		tc.ExpectedSecret = opaqueValueSecret("before-restart")
+
+		var runtime *OperationalRuntime
+		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			runtime = harness.PrepareCluster(tc, ClusterProviderConfig{
+				Name:      "operational-restart",
+				AuthScope: authScope,
+			})
+			applyOperationalExternalSecret(tc, runtime)
+		}
+		tc.AfterSync = func(prov framework.SecretStoreProvider, _ *corev1.Secret) {
+			skipIfOperationalRuntimeMissingRestart(runtime)
+			runtime.RestartBackend()
+			waitForProviderRefCondition(tc.Framework, tc.ExternalSecret.Namespace, runtime.ProviderRef, metav1.ConditionTrue)
+
+			prov.DeleteSecret(remoteKey)
+			prov.CreateSecret(remoteKey, framework.SecretEntry{Value: jsonSecretValue(expectedValue)})
+
+			waitForExternalSecretStatus(tc.Framework, tc.ExternalSecret.Namespace, tc.ExternalSecret.Name, corev1.ConditionTrue)
+			waitForSecretData(tc.Framework, tc.ExternalSecret.Namespace, tc.ExternalSecret.Spec.Target.Name, map[string][]byte{
+				"value": []byte(expectedValue),
+			}, operationalTimeout)
+		}
+	}
+}
+
+func NamespacedPushSecretUnavailable(f *framework.Framework, harness OperationalPushSecretHarness) (string, func(*framework.TestCase)) {
+	return "[common] should surface namespaced Provider push unavailability and recover after backend restoration", func(tc *framework.TestCase) {
+		tc.PushSecretSource = operationalPushSourceSecret(f, "operational-push-namespaced-source", "before-outage")
+
+		var runtime *OperationalRuntime
+		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			runtime = harness.PrepareNamespaced(tc)
+			remoteSecretName := f.MakeRemoteRefKey("operational-push-namespaced-remote")
+			applyOperationalPushSecret(tc, runtime, remoteSecretName)
+			tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+				waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionTrue)
+				runtime.WaitForRemoteSecret(runtime.DefaultRemoteNamespace, remoteSecretName, "value", "before-outage")
+
+				skipIfOperationalRuntimeMissingDisruptionLifecycle(runtime)
+				DeferCleanup(func() {
+					runtime.RestoreAvailability()
+					waitForProviderRefCondition(tc.Framework, ps.Namespace, runtime.ProviderRef, metav1.ConditionTrue)
+				})
+				runtime.MakeUnavailable()
+				waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionFalse)
+
+				runtime.RestoreAvailability()
+				waitForProviderRefCondition(tc.Framework, ps.Namespace, runtime.ProviderRef, metav1.ConditionTrue)
+				updatePushSecretSource(tc.Framework, tc.PushSecretSource.Namespace, tc.PushSecretSource.Name, "after-outage")
+				waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionTrue)
+				runtime.WaitForRemoteSecret(runtime.DefaultRemoteNamespace, remoteSecretName, "value", "after-outage")
+			}
+		}
+	}
+}
+
+func ClusterProviderPushUnavailable(f *framework.Framework, harness OperationalPushSecretHarness, authScope esv1.AuthenticationScope) (string, func(*framework.TestCase)) {
+	return fmt.Sprintf("[common] should surface ClusterProvider push unavailability and recover with %s auth", authScope), func(tc *framework.TestCase) {
+		scopeSuffix := operationalScopeSuffix(authScope)
+		tc.PushSecretSource = operationalPushSourceSecret(f, fmt.Sprintf("operational-push-cluster-source-%s", scopeSuffix), "before-outage")
+
+		var runtime *OperationalRuntime
+		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			runtime = harness.PrepareCluster(tc, ClusterProviderConfig{
+				Name:      "operational-push-unavailable",
+				AuthScope: authScope,
+			})
+			remoteSecretName := f.MakeRemoteRefKey(fmt.Sprintf("operational-push-cluster-remote-%s", scopeSuffix))
+			applyOperationalPushSecret(tc, runtime, remoteSecretName)
+			tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+				waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionTrue)
+				runtime.WaitForRemoteSecret(runtime.DefaultRemoteNamespace, remoteSecretName, "value", "before-outage")
+
+				skipIfOperationalRuntimeMissingDisruptionLifecycle(runtime)
+				DeferCleanup(func() {
+					runtime.RestoreAvailability()
+					waitForProviderRefCondition(tc.Framework, ps.Namespace, runtime.ProviderRef, metav1.ConditionTrue)
+				})
+				runtime.MakeUnavailable()
+				waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionFalse)
+
+				runtime.RestoreAvailability()
+				waitForProviderRefCondition(tc.Framework, ps.Namespace, runtime.ProviderRef, metav1.ConditionTrue)
+				updatePushSecretSource(tc.Framework, tc.PushSecretSource.Namespace, tc.PushSecretSource.Name, "after-outage")
+				waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionTrue)
+				runtime.WaitForRemoteSecret(runtime.DefaultRemoteNamespace, remoteSecretName, "value", "after-outage")
+			}
+		}
+	}
+}
+
+func applyOperationalExternalSecret(tc *framework.TestCase, runtime *OperationalRuntime) {
+	Expect(runtime).NotTo(BeNil(), "operational harness returned nil runtime")
+	tc.ExternalSecret.Spec.SecretStoreRef = runtime.ProviderRef
+	if runtime.Provider != nil {
+		tc.ProviderOverride = runtime.Provider
+	}
+}
+
+func applyOperationalPushSecret(tc *framework.TestCase, runtime *OperationalRuntime, remoteSecretName string) {
+	Expect(runtime).NotTo(BeNil(), "operational harness returned nil runtime")
+	Expect(runtime.ProviderRef.Name).NotTo(BeEmpty(), "operational runtime provider ref name must be set")
+	Expect(runtime.WaitForRemoteSecret).NotTo(BeNil(), "operational runtime WaitForRemoteSecret hook must be set")
+
+	tc.PushSecret.ObjectMeta.Name = fmt.Sprintf("%s-push-secret", tc.PushSecretSource.Name)
+	tc.PushSecret.Spec.SecretStoreRefs = []esv1alpha1.PushSecretStoreRef{{
+		Name: runtime.ProviderRef.Name,
+		Kind: runtime.ProviderRef.Kind,
+	}}
+	tc.PushSecret.Spec.Selector = esv1alpha1.PushSecretSelector{
+		Secret: &esv1alpha1.PushSecretSecret{
+			Name: tc.PushSecretSource.Name,
+		},
+	}
+	tc.PushSecret.Spec.Data = []esv1alpha1.PushSecretData{{
+		Match: esv1alpha1.PushSecretMatch{
+			SecretKey: "value",
+			RemoteRef: esv1alpha1.PushSecretRemoteRef{
+				RemoteKey: remoteSecretName,
+				Property:  "value",
+			},
+		},
+	}}
+}
+
+func waitForProviderRefCondition(f *framework.Framework, namespace string, ref esv1.SecretStoreRef, status metav1.ConditionStatus) {
+	switch ref.Kind {
+	case esv1.ClusterProviderStoreKindStr:
+		frameworkv2.WaitForClusterProviderCondition(f, ref.Name, status, operationalTimeout)
+	default:
+		frameworkv2.WaitForProviderConnectionCondition(f, namespace, ref.Name, status, operationalTimeout)
+	}
+}
+
+func skipIfOperationalRuntimeMissingDisruptionLifecycle(runtime *OperationalRuntime) {
+	Expect(runtime).NotTo(BeNil(), "operational harness returned nil runtime")
+	if !runtime.SupportsDisruptionLifecycle() {
+		Skip(fmt.Sprintf("provider ref %q does not support disruption lifecycle hooks", runtime.ProviderRef.Name))
+	}
+}
+
+func skipIfOperationalRuntimeMissingRestart(runtime *OperationalRuntime) {
+	Expect(runtime).NotTo(BeNil(), "operational harness returned nil runtime")
+	if !runtime.SupportsRestart() {
+		Skip(fmt.Sprintf("provider ref %q does not support restart hooks", runtime.ProviderRef.Name))
+	}
+}
+
+func operationalPushSourceSecret(f *framework.Framework, name, value string) *corev1.Secret {
+	return &corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: f.Namespace.Name,
+		},
+		Data: map[string][]byte{
+			"value": []byte(value),
+		},
+	}
+}
+
+func updatePushSecretSource(f *framework.Framework, namespace, name, value string) {
+	Eventually(func(g Gomega) {
+		var secret corev1.Secret
+		g.Expect(f.CRClient.Get(context.Background(), types.NamespacedName{Name: name, Namespace: namespace}, &secret)).To(Succeed())
+		secret.Data["value"] = []byte(value)
+		g.Expect(f.CRClient.Update(context.Background(), &secret)).To(Succeed())
+	}, operationalTimeout, operationalPollInterval).Should(Succeed())
+}
+
+func opaqueValueSecret(value string) *corev1.Secret {
+	return &corev1.Secret{
+		Type: corev1.SecretTypeOpaque,
+		Data: map[string][]byte{
+			"value": []byte(value),
+		},
+	}
+}
+
+func operationalScopeSuffix(authScope esv1.AuthenticationScope) string {
+	replacer := strings.NewReplacer(
+		"ManifestNamespace", "manifest-namespace",
+		"ProviderNamespace", "provider-namespace",
+	)
+	return replacer.Replace(string(authScope))
+}

+ 60 - 0
e2e/suites/provider/cases/common/operational_v2_test.go

@@ -0,0 +1,60 @@
+/*
+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 common
+
+import "testing"
+
+func TestOperationalRuntimeSupportsDisruptionLifecycle(t *testing.T) {
+	runtimeWithoutHooks := &OperationalRuntime{}
+	if runtimeWithoutHooks.SupportsDisruptionLifecycle() {
+		t.Fatalf("expected false when all hooks are nil")
+	}
+
+	runtimeWithBreakOnly := &OperationalRuntime{
+		MakeUnavailable: func() {},
+	}
+	if runtimeWithBreakOnly.SupportsDisruptionLifecycle() {
+		t.Fatalf("expected false when RestoreAvailability is nil")
+	}
+
+	runtimeWithRestoreOnly := &OperationalRuntime{
+		RestoreAvailability: func() {},
+	}
+	if runtimeWithRestoreOnly.SupportsDisruptionLifecycle() {
+		t.Fatalf("expected false when MakeUnavailable is nil")
+	}
+
+	runtimeWithBoth := &OperationalRuntime{
+		MakeUnavailable:     func() {},
+		RestoreAvailability: func() {},
+	}
+	if !runtimeWithBoth.SupportsDisruptionLifecycle() {
+		t.Fatalf("expected true when both hooks exist")
+	}
+}
+
+func TestOperationalRuntimeSupportsRestart(t *testing.T) {
+	runtime := &OperationalRuntime{}
+	if runtime.SupportsRestart() {
+		t.Fatalf("expected false when RestartBackend is nil")
+	}
+
+	runtime.RestartBackend = func() {}
+	if !runtime.SupportsRestart() {
+		t.Fatalf("expected true when RestartBackend is present")
+	}
+}

+ 70 - 0
e2e/suites/provider/cases/common/provider_namespace.go

@@ -0,0 +1,70 @@
+/*
+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 common
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/wait"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+func CreateProviderCaseNamespace(f *framework.Framework, prefix string, pollInterval time.Duration) string {
+	if pollInterval <= 0 {
+		pollInterval = 5 * time.Second
+	}
+
+	namespace := &corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			GenerateName: fmt.Sprintf("e2e-tests-%s-", prefix),
+		},
+	}
+	Expect(f.CRClient.Create(context.Background(), namespace)).To(Succeed())
+
+	DeferCleanup(func() {
+		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+		defer cancel()
+
+		err := f.CRClient.Delete(ctx, namespace)
+		if err != nil && !apierrors.IsNotFound(err) {
+			Expect(err).ToNot(HaveOccurred())
+		}
+
+		err = wait.PollUntilContextTimeout(ctx, pollInterval, 2*time.Minute, true, func(ctx context.Context) (bool, error) {
+			_, err := f.KubeClientSet.CoreV1().Namespaces().Get(ctx, namespace.Name, metav1.GetOptions{})
+			if apierrors.IsNotFound(err) {
+				return true, nil
+			}
+			if err != nil {
+				return false, err
+			}
+			return false, nil
+		})
+		Expect(err).To(Succeed())
+	})
+
+	return namespace.Name
+}

+ 155 - 0
e2e/suites/provider/cases/common/provider_runtime_test.go

@@ -0,0 +1,155 @@
+/*
+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 common
+
+import (
+	"strings"
+	"testing"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+)
+
+func TestClusterProviderExternalSecretRuntimeSupportsAuthLifecycle(t *testing.T) {
+	runtimeWithoutHooks := &ClusterProviderExternalSecretRuntime{}
+	if runtimeWithoutHooks.SupportsAuthLifecycle() {
+		t.Fatalf("expected SupportsAuthLifecycle to return false when both hooks are nil")
+	}
+
+	runtimeWithBreakOnly := &ClusterProviderExternalSecretRuntime{
+		BreakAuth: func() {},
+	}
+	if runtimeWithBreakOnly.SupportsAuthLifecycle() {
+		t.Fatalf("expected SupportsAuthLifecycle to return false when RepairAuth is nil")
+	}
+
+	runtimeWithRepairOnly := &ClusterProviderExternalSecretRuntime{
+		RepairAuth: func() {},
+	}
+	if runtimeWithRepairOnly.SupportsAuthLifecycle() {
+		t.Fatalf("expected SupportsAuthLifecycle to return false when BreakAuth is nil")
+	}
+
+	runtimeWithBothHooks := &ClusterProviderExternalSecretRuntime{
+		BreakAuth:  func() {},
+		RepairAuth: func() {},
+	}
+	if !runtimeWithBothHooks.SupportsAuthLifecycle() {
+		t.Fatalf("expected SupportsAuthLifecycle to return true when both hooks are present")
+	}
+}
+
+func TestClusterProviderPushRuntimeSupportsAuthLifecycle(t *testing.T) {
+	runtimeWithoutHooks := &ClusterProviderPushRuntime{}
+	if runtimeWithoutHooks.SupportsAuthLifecycle() {
+		t.Fatalf("expected SupportsAuthLifecycle to return false when both hooks are nil")
+	}
+
+	runtimeWithBreakOnly := &ClusterProviderPushRuntime{
+		BreakAuth: func() {},
+	}
+	if runtimeWithBreakOnly.SupportsAuthLifecycle() {
+		t.Fatalf("expected SupportsAuthLifecycle to return false when RepairAuth is nil")
+	}
+
+	runtimeWithRepairOnly := &ClusterProviderPushRuntime{
+		RepairAuth: func() {},
+	}
+	if runtimeWithRepairOnly.SupportsAuthLifecycle() {
+		t.Fatalf("expected SupportsAuthLifecycle to return false when BreakAuth is nil")
+	}
+
+	runtimeWithBothHooks := &ClusterProviderPushRuntime{
+		BreakAuth:  func() {},
+		RepairAuth: func() {},
+	}
+	if !runtimeWithBothHooks.SupportsAuthLifecycle() {
+		t.Fatalf("expected SupportsAuthLifecycle to return true when both hooks are present")
+	}
+}
+
+func TestClusterProviderPushRuntimeSupportsRemoteAbsenceAssertions(t *testing.T) {
+	runtimeWithoutExpectation := &ClusterProviderPushRuntime{}
+	if runtimeWithoutExpectation.SupportsRemoteAbsenceAssertions() {
+		t.Fatalf("expected SupportsRemoteAbsenceAssertions to return false when ExpectNoRemoteSecret is nil")
+	}
+
+	runtimeWithExpectation := &ClusterProviderPushRuntime{
+		ExpectNoRemoteSecret: func(_, _ string) {},
+	}
+	if !runtimeWithExpectation.SupportsRemoteAbsenceAssertions() {
+		t.Fatalf("expected SupportsRemoteAbsenceAssertions to return true when ExpectNoRemoteSecret is present")
+	}
+}
+
+func TestClusterProviderPushRuntimeSupportsRemoteNamespaceOverrides(t *testing.T) {
+	runtimeWithoutFactory := &ClusterProviderPushRuntime{}
+	if runtimeWithoutFactory.SupportsRemoteNamespaceOverrides() {
+		t.Fatalf("expected SupportsRemoteNamespaceOverrides to return false when CreateWritableRemoteScope is nil")
+	}
+
+	runtimeWithFactory := &ClusterProviderPushRuntime{
+		CreateWritableRemoteScope: func(_ string) string { return "override-namespace" },
+	}
+	if !runtimeWithFactory.SupportsRemoteNamespaceOverrides() {
+		t.Fatalf("expected SupportsRemoteNamespaceOverrides to return true when CreateWritableRemoteScope is present")
+	}
+}
+
+func TestApplyClusterProviderPushSecretPanicsWithClearMessageWhenRuntimeNil(t *testing.T) {
+	defer func() {
+		recovered := recover()
+		if recovered == nil {
+			t.Fatalf("expected panic when runtime is nil")
+		}
+		message, ok := recovered.(string)
+		if !ok {
+			t.Fatalf("expected panic message to be string, got %T", recovered)
+		}
+		if !strings.Contains(message, "cluster provider push harness returned nil runtime") {
+			t.Fatalf("expected panic message to mention nil runtime guard, got %q", message)
+		}
+	}()
+
+	applyClusterProviderPushSecret(nil, nil, "remote-secret")
+}
+
+func TestApplyClusterProviderPushSecretUsesSafeObjectNameIndependentOfRemoteKey(t *testing.T) {
+	tc := &framework.TestCase{
+		PushSecret: &esv1alpha1.PushSecret{},
+		PushSecretSource: &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: "push-provider-source",
+			},
+		},
+	}
+	runtime := &ClusterProviderPushRuntime{
+		ClusterProviderName: "push-provider-cluster-provider",
+	}
+
+	applyClusterProviderPushSecret(tc, runtime, "/e2e/test-ns/push-provider-remote")
+
+	if got, want := tc.PushSecret.ObjectMeta.Name, "push-provider-source-push-secret"; got != want {
+		t.Fatalf("expected PushSecret name %q, got %q", want, got)
+	}
+	if got, want := tc.PushSecret.Spec.Data[0].Match.RemoteRef.RemoteKey, "/e2e/test-ns/push-provider-remote"; got != want {
+		t.Fatalf("expected remote key %q, got %q", want, got)
+	}
+}

+ 434 - 0
e2e/suites/provider/cases/common/push_secret.go

@@ -0,0 +1,434 @@
+/*
+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 common
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	corev1 "k8s.io/api/core/v1"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/wait"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+type ClusterProviderPushHarness struct {
+	Prepare func(tc *framework.TestCase, cfg ClusterProviderConfig) *ClusterProviderPushRuntime
+}
+
+type ClusterProviderPushRuntime struct {
+	ClusterProviderName       string
+	DefaultRemoteNamespace    string
+	BreakAuth                 func()
+	RepairAuth                func()
+	WaitForRemoteSecretValue  func(namespace, name, key, expectedValue string)
+	ExpectNoRemoteSecret      func(namespace, name string)
+	CreateWritableRemoteScope func(prefix string) string
+}
+
+func (r *ClusterProviderPushRuntime) SupportsAuthLifecycle() bool {
+	return r != nil && r.BreakAuth != nil && r.RepairAuth != nil
+}
+
+func (r *ClusterProviderPushRuntime) SupportsRemoteAbsenceAssertions() bool {
+	return r != nil && r.ExpectNoRemoteSecret != nil
+}
+
+func (r *ClusterProviderPushRuntime) SupportsRemoteNamespaceOverrides() bool {
+	return r != nil && r.CreateWritableRemoteScope != nil
+}
+
+func PushSecretPreservesSourceMetadata(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[common] should preserve source secret type, labels, and annotations when pushing to the namespaced Provider", func(tc *framework.TestCase) {
+		tc.PushSecretSource = &corev1.Secret{
+			Type: corev1.SecretTypeDockerConfigJson,
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "source-secret-metadata",
+				Namespace: f.Namespace.Name,
+				Labels: map[string]string{
+					"team": "platform",
+				},
+				Annotations: map[string]string{
+					"owner": "eso",
+				},
+			},
+			Data: map[string][]byte{
+				corev1.DockerConfigJsonKey: []byte(`{"auths":{"registry.example.com":{"auth":"ZXNvOnNlY3JldA=="}}}`),
+			},
+		}
+		tc.PushSecret.ObjectMeta.Name = "test-pushsecret-metadata"
+		tc.PushSecret.Spec.Selector = esv1alpha1.PushSecretSelector{
+			Secret: &esv1alpha1.PushSecretSecret{
+				Name: tc.PushSecretSource.Name,
+			},
+		}
+		tc.PushSecret.Spec.Data = []esv1alpha1.PushSecretData{{
+			Match: esv1alpha1.PushSecretMatch{
+				SecretKey: corev1.DockerConfigJsonKey,
+				RemoteRef: esv1alpha1.PushSecretRemoteRef{
+					RemoteKey: "pushed-docker-secret",
+					Property:  corev1.DockerConfigJsonKey,
+				},
+			},
+		}}
+		tc.VerifyPushSecretOutcome = func(_ *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+			waitForPushSecretStatus(tc.Framework, tc.PushSecret.Namespace, tc.PushSecret.Name, corev1.ConditionTrue)
+
+			var pushedSecret corev1.Secret
+			Eventually(func(g Gomega) {
+				g.Expect(tc.Framework.CRClient.Get(context.Background(), types.NamespacedName{
+					Name:      "pushed-docker-secret",
+					Namespace: f.Namespace.Name,
+				}, &pushedSecret)).To(Succeed())
+			}, time.Minute, 5*time.Second).Should(Succeed())
+
+			Expect(pushedSecret.Type).To(Equal(tc.PushSecretSource.Type))
+			Expect(pushedSecret.Labels).To(Equal(tc.PushSecretSource.Labels))
+			Expect(pushedSecret.Annotations).To(Equal(tc.PushSecretSource.Annotations))
+			Expect(pushedSecret.Data).To(Equal(tc.PushSecretSource.Data))
+		}
+	}
+}
+
+func PushSecretImplicitProviderKind(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[common] should support namespaced Provider refs when kind is omitted", func(tc *framework.TestCase) {
+		tc.PushSecretSource = &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "source-secret-implicit-kind",
+				Namespace: f.Namespace.Name,
+			},
+			Data: map[string][]byte{
+				"key1": []byte("value1"),
+			},
+		}
+		tc.PushSecret.ObjectMeta.Name = "test-pushsecret-implicit-kind"
+		tc.PushSecret.Spec.DeletionPolicy = esv1alpha1.PushSecretDeletionPolicyDelete
+		tc.PushSecret.Spec.SecretStoreRefs[0].Kind = ""
+		tc.PushSecret.Spec.Selector = esv1alpha1.PushSecretSelector{
+			Secret: &esv1alpha1.PushSecretSecret{
+				Name: tc.PushSecretSource.Name,
+			},
+		}
+		tc.PushSecret.Spec.Data = []esv1alpha1.PushSecretData{{
+			Match: esv1alpha1.PushSecretMatch{
+				SecretKey: "key1",
+				RemoteRef: esv1alpha1.PushSecretRemoteRef{
+					RemoteKey: "pushed-secret-implicit-kind",
+					Property:  "key1",
+				},
+			},
+		}}
+		tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+			waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionTrue)
+			Eventually(func(g Gomega) {
+				var pushedSecret corev1.Secret
+				g.Expect(tc.Framework.CRClient.Get(context.Background(), types.NamespacedName{
+					Name:      "pushed-secret-implicit-kind",
+					Namespace: f.Namespace.Name,
+				}, &pushedSecret)).To(Succeed())
+				g.Expect(string(pushedSecret.Data["key1"])).To(Equal("value1"))
+			}, time.Minute, 5*time.Second).Should(Succeed())
+
+			Expect(tc.Framework.CRClient.Delete(context.Background(), ps)).To(Succeed())
+			Eventually(func() bool {
+				var pushedSecret corev1.Secret
+				err := tc.Framework.CRClient.Get(context.Background(), types.NamespacedName{
+					Name:      "pushed-secret-implicit-kind",
+					Namespace: f.Namespace.Name,
+				}, &pushedSecret)
+				return apierrors.IsNotFound(err)
+			}, time.Minute, 5*time.Second).Should(BeTrue())
+		}
+	}
+}
+
+func PushSecretRejectsNamespacedRemoteNamespaceOverride(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[common] should reject remote namespace overrides when pushing through a namespaced Provider", func(tc *framework.TestCase) {
+		overrideNamespace := createE2ENamespace(tc.Framework, "push-provider-override")
+		tc.PushSecretSource = &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "source-secret-provider-override",
+				Namespace: f.Namespace.Name,
+			},
+			Data: map[string][]byte{
+				"value": []byte("should-not-push"),
+			},
+		}
+		tc.PushSecret.ObjectMeta.Name = "test-pushsecret-provider-override"
+		tc.PushSecret.Spec.Selector = esv1alpha1.PushSecretSelector{
+			Secret: &esv1alpha1.PushSecretSecret{
+				Name: tc.PushSecretSource.Name,
+			},
+		}
+		tc.PushSecret.Spec.Data = []esv1alpha1.PushSecretData{{
+			Match: esv1alpha1.PushSecretMatch{
+				SecretKey: "value",
+				RemoteRef: esv1alpha1.PushSecretRemoteRef{
+					RemoteKey: "pushed-secret-provider-override",
+					Property:  "value",
+				},
+			},
+			Metadata: pushSecretMetadataWithRemoteNamespace(overrideNamespace),
+		}}
+		tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+			waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionFalse)
+			expectNoSecretInNamespace(tc.Framework, f.Namespace.Name, "pushed-secret-provider-override")
+			expectNoSecretInNamespace(tc.Framework, overrideNamespace, "pushed-secret-provider-override")
+			expectEventMessage(tc.Framework, ps.Namespace, ps.Name, "PushSecret", "remoteNamespace override is only supported with ClusterSecretStore")
+		}
+	}
+}
+
+func ClusterProviderPushManifestNamespace(f *framework.Framework, harness ClusterProviderPushHarness) (string, func(*framework.TestCase)) {
+	return clusterProviderPushSyncCase(f, harness, "push-manifest", "manifest-push-value", esv1.AuthenticationScopeManifestNamespace)
+}
+
+func ClusterProviderPushProviderNamespace(f *framework.Framework, harness ClusterProviderPushHarness) (string, func(*framework.TestCase)) {
+	return clusterProviderPushSyncCase(f, harness, "push-provider", "provider-push-value", esv1.AuthenticationScopeProviderNamespace)
+}
+
+func ClusterProviderPushManifestNamespaceRecovery(f *framework.Framework, harness ClusterProviderPushHarness) (string, func(*framework.TestCase)) {
+	return clusterProviderPushRecoveryCase(f, harness, "push-manifest-recovery", "manifest-push-recovered", esv1.AuthenticationScopeManifestNamespace)
+}
+
+func ClusterProviderPushProviderNamespaceRecovery(f *framework.Framework, harness ClusterProviderPushHarness) (string, func(*framework.TestCase)) {
+	return clusterProviderPushRecoveryCase(f, harness, "push-provider-recovery", "provider-push-recovered", esv1.AuthenticationScopeProviderNamespace)
+}
+
+func ClusterProviderPushAllowsRemoteNamespaceOverride(f *framework.Framework, harness ClusterProviderPushHarness) (string, func(*framework.TestCase)) {
+	return "[common] should allow ClusterProvider pushes to override the target remote namespace via metadata", func(tc *framework.TestCase) {
+		tc.PushSecretSource = &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "push-remote-override-source",
+				Namespace: f.Namespace.Name,
+			},
+			Data: map[string][]byte{
+				"value": []byte("override-push-value"),
+			},
+		}
+
+		var runtime *ClusterProviderPushRuntime
+		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			remoteSecretName := f.MakeRemoteRefKey("push-remote-override-remote")
+			runtime = harness.Prepare(tc, ClusterProviderConfig{
+				Name:      "push-remote-override",
+				AuthScope: esv1.AuthenticationScopeManifestNamespace,
+			})
+			applyClusterProviderPushSecret(tc, runtime, remoteSecretName)
+			if !runtime.SupportsRemoteNamespaceOverrides() {
+				Skip(fmt.Sprintf("provider %q does not support remote namespace override hooks", runtime.ClusterProviderName))
+			}
+			overrideNamespace := runtime.CreateWritableRemoteScope("push-remote-override-target")
+			tc.PushSecret.Spec.Data[0].Metadata = pushSecretMetadataWithRemoteNamespace(overrideNamespace)
+			tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+				waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionTrue)
+				runtime.WaitForRemoteSecretValue(overrideNamespace, remoteSecretName, "value", "override-push-value")
+				if runtime.SupportsRemoteAbsenceAssertions() {
+					runtime.ExpectNoRemoteSecret(runtime.DefaultRemoteNamespace, remoteSecretName)
+				}
+			}
+		}
+	}
+}
+
+func ClusterProviderPushDeniedByConditions(f *framework.Framework, harness ClusterProviderPushHarness) (string, func(*framework.TestCase)) {
+	return "[common] should deny PushSecrets from namespaces that do not match ClusterProvider conditions", func(tc *framework.TestCase) {
+		tc.PushSecretSource = &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "push-deny-source",
+				Namespace: f.Namespace.Name,
+			},
+			Data: map[string][]byte{
+				"value": []byte("should-not-push"),
+			},
+		}
+
+		var runtime *ClusterProviderPushRuntime
+		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			remoteSecretName := f.MakeRemoteRefKey("push-deny-remote")
+			runtime = harness.Prepare(tc, ClusterProviderConfig{
+				Name:      "push-deny",
+				AuthScope: esv1.AuthenticationScopeManifestNamespace,
+				Conditions: []esv1.ClusterSecretStoreCondition{{
+					Namespaces: []string{"not-" + f.Namespace.Name},
+				}},
+			})
+			applyClusterProviderPushSecret(tc, runtime, remoteSecretName)
+			tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+				waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionFalse)
+				if runtime.SupportsRemoteAbsenceAssertions() {
+					runtime.ExpectNoRemoteSecret(runtime.DefaultRemoteNamespace, remoteSecretName)
+				}
+				expectEventMessage(tc.Framework, ps.Namespace, ps.Name, "PushSecret", fmt.Sprintf("using ClusterProviderStore %q is not allowed from namespace %q: denied by spec.conditions", runtime.ClusterProviderName, f.Namespace.Name))
+			}
+		}
+	}
+}
+
+func clusterProviderPushSyncCase(f *framework.Framework, harness ClusterProviderPushHarness, name, expectedValue string, authScope esv1.AuthenticationScope) (string, func(*framework.TestCase)) {
+	return fmt.Sprintf("[common] should push through a ClusterProvider with %s auth", authScope), func(tc *framework.TestCase) {
+		tc.PushSecretSource = &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      fmt.Sprintf("%s-source", name),
+				Namespace: f.Namespace.Name,
+			},
+			Data: map[string][]byte{
+				"value": []byte(expectedValue),
+			},
+		}
+
+		var runtime *ClusterProviderPushRuntime
+		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			remoteSecretName := f.MakeRemoteRefKey(fmt.Sprintf("%s-remote", name))
+			runtime = harness.Prepare(tc, ClusterProviderConfig{
+				Name:      name,
+				AuthScope: authScope,
+			})
+			applyClusterProviderPushSecret(tc, runtime, remoteSecretName)
+			tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+				waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionTrue)
+				runtime.WaitForRemoteSecretValue(runtime.DefaultRemoteNamespace, remoteSecretName, "value", expectedValue)
+			}
+		}
+	}
+}
+
+func clusterProviderPushRecoveryCase(f *framework.Framework, harness ClusterProviderPushHarness, name, expectedValue string, authScope esv1.AuthenticationScope) (string, func(*framework.TestCase)) {
+	return fmt.Sprintf("[common] should recover after repairing ClusterProvider push auth with %s scope", authScope), func(tc *framework.TestCase) {
+		tc.PushSecretSource = &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      fmt.Sprintf("%s-source", name),
+				Namespace: f.Namespace.Name,
+			},
+			Data: map[string][]byte{
+				"value": []byte(expectedValue),
+			},
+		}
+
+		var runtime *ClusterProviderPushRuntime
+		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			remoteSecretName := f.MakeRemoteRefKey(fmt.Sprintf("%s-remote", name))
+			runtime = harness.Prepare(tc, ClusterProviderConfig{
+				Name:      name,
+				AuthScope: authScope,
+			})
+			applyClusterProviderPushSecret(tc, runtime, remoteSecretName)
+			if !runtime.SupportsAuthLifecycle() {
+				Skip(fmt.Sprintf("provider %q does not support auth lifecycle recovery hooks", runtime.ClusterProviderName))
+			}
+			tc.PushSecret.Spec.RefreshInterval = &metav1.Duration{Duration: time.Hour}
+			runtime.BreakAuth()
+			tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+				waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionFalse)
+				if runtime.SupportsRemoteAbsenceAssertions() {
+					runtime.ExpectNoRemoteSecret(runtime.DefaultRemoteNamespace, remoteSecretName)
+				}
+				runtime.RepairAuth()
+				waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionTrue)
+				runtime.WaitForRemoteSecretValue(runtime.DefaultRemoteNamespace, remoteSecretName, "value", expectedValue)
+			}
+		}
+	}
+}
+
+func applyClusterProviderPushSecret(tc *framework.TestCase, runtime *ClusterProviderPushRuntime, remoteSecretName string) {
+	if runtime == nil {
+		panic("cluster provider push harness returned nil runtime")
+	}
+
+	tc.PushSecret.ObjectMeta.Name = fmt.Sprintf("%s-push-secret", tc.PushSecretSource.Name)
+	tc.PushSecret.Spec.SecretStoreRefs = []esv1alpha1.PushSecretStoreRef{{
+		Name: runtime.ClusterProviderName,
+		Kind: esv1.ClusterProviderStoreKindStr,
+	}}
+	tc.PushSecret.Spec.Selector = esv1alpha1.PushSecretSelector{
+		Secret: &esv1alpha1.PushSecretSecret{
+			Name: tc.PushSecretSource.Name,
+		},
+	}
+	tc.PushSecret.Spec.Data = []esv1alpha1.PushSecretData{{
+		Match: esv1alpha1.PushSecretMatch{
+			SecretKey: "value",
+			RemoteRef: esv1alpha1.PushSecretRemoteRef{
+				RemoteKey: remoteSecretName,
+				Property:  "value",
+			},
+		},
+	}}
+}
+
+func waitForPushSecretStatus(f *framework.Framework, namespace, name string, status corev1.ConditionStatus) {
+	Eventually(func(g Gomega) {
+		var ps esv1alpha1.PushSecret
+		g.Expect(f.CRClient.Get(context.Background(), types.NamespacedName{Name: name, Namespace: namespace}, &ps)).To(Succeed())
+		g.Expect(ps.Status.Conditions).NotTo(BeEmpty())
+		ready := false
+		for _, condition := range ps.Status.Conditions {
+			if condition.Type == esv1alpha1.PushSecretReady && condition.Status == status {
+				ready = true
+			}
+		}
+		g.Expect(ready).To(BeTrue())
+	}, time.Minute, 5*time.Second).Should(Succeed())
+}
+
+func pushSecretMetadataWithRemoteNamespace(namespace string) *apiextensionsv1.JSON {
+	return &apiextensionsv1.JSON{Raw: []byte(fmt.Sprintf(`{"apiVersion":"kubernetes.external-secrets.io/v1alpha1","kind":"PushSecretMetadata","spec":{"remoteNamespace":"%s"}}`, namespace))}
+}
+
+func createE2ENamespace(f *framework.Framework, prefix string) string {
+	namespace := &corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			GenerateName: fmt.Sprintf("e2e-tests-%s-", prefix),
+		},
+	}
+	Expect(f.CRClient.Create(context.Background(), namespace)).To(Succeed())
+
+	DeferCleanup(func() {
+		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+		defer cancel()
+
+		err := f.CRClient.Delete(ctx, namespace)
+		if err != nil && !apierrors.IsNotFound(err) {
+			Expect(err).ToNot(HaveOccurred())
+		}
+
+		err = wait.PollUntilContextTimeout(ctx, 5*time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) {
+			_, err := f.KubeClientSet.CoreV1().Namespaces().Get(ctx, namespace.Name, metav1.GetOptions{})
+			if apierrors.IsNotFound(err) {
+				return true, nil
+			}
+			if err != nil {
+				return false, err
+			}
+			return false, nil
+		})
+		Expect(err).To(Succeed())
+	})
+
+	return namespace.Name
+}

+ 3 - 2
e2e/suites/provider/cases/common/regressions.go

@@ -19,14 +19,15 @@ package common
 import (
 	"time"
 
-	. "github.com/onsi/ginkgo/v2"
-	. "github.com/onsi/gomega"
 	v1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 
 	"github.com/external-secrets/external-secrets-e2e/framework"
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
 )
 
 // StatusNotUpdatedAfterSuccessfulSync validates that a successful sync does not trigger

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

@@ -17,7 +17,6 @@ limitations under the License.
 package suite
 
 import (
-
 	// import different e2e test suites.
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws/parameterstore"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws/secretsmanager"

+ 32 - 13
e2e/suites/provider/suite_test.go

@@ -19,20 +19,33 @@ package e2e
 import (
 	"testing"
 
-	// nolint
-	. "github.com/onsi/ginkgo/v2"
-
-	// nolint
-	. "github.com/onsi/gomega"
-
+	"github.com/external-secrets/external-secrets-e2e/framework"
 	"github.com/external-secrets/external-secrets-e2e/framework/addon"
 	"github.com/external-secrets/external-secrets-e2e/framework/util"
-	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases"
 	v1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
 	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
+
+	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases"
+
+	// nolint
+	. "github.com/onsi/ginkgo/v2"
+	// nolint
+	. "github.com/onsi/gomega"
 )
 
 var _ = SynchronizedBeforeSuite(func() []byte {
+	if framework.IsV2ProviderMode() {
+		By("installing eso in provider v2 mode")
+		addon.InstallGlobalAddon(addon.NewESO(
+			addon.WithCRDs(),
+			addon.WithV2Namespace(),
+			addon.WithV2KubernetesProvider(),
+			addon.WithV2FakeProvider(),
+			addon.WithV2AWSProvider(),
+		))
+		return nil
+	}
+
 	By("installing eso")
 	addon.InstallGlobalAddon(addon.NewESO(addon.WithCRDs()))
 
@@ -50,18 +63,24 @@ var _ = SynchronizedAfterSuite(func() {
 	By("Deleting any pending generator states")
 	generatorStates := &genv1alpha1.GeneratorStateList{}
 	err := cfg.CRClient.List(GinkgoT().Context(), generatorStates)
-	Expect(err).ToNot(HaveOccurred())
-	for _, generatorState := range generatorStates.Items {
-		err = cfg.CRClient.Delete(GinkgoT().Context(), &generatorState)
+	if err == nil {
+		for _, generatorState := range generatorStates.Items {
+			err = cfg.CRClient.Delete(GinkgoT().Context(), &generatorState)
+			Expect(err).ToNot(HaveOccurred())
+		}
+	} else if !util.IsMissingAPIResourceError(err) {
 		Expect(err).ToNot(HaveOccurred())
 	}
 
 	By("Deleting all ClusterExternalSecrets")
 	externalSecretsList := &v1.ClusterExternalSecretList{}
 	err = cfg.CRClient.List(GinkgoT().Context(), externalSecretsList)
-	Expect(err).ToNot(HaveOccurred())
-	for _, externalSecret := range externalSecretsList.Items {
-		err = cfg.CRClient.Delete(GinkgoT().Context(), &externalSecret)
+	if err == nil {
+		for _, externalSecret := range externalSecretsList.Items {
+			err = cfg.CRClient.Delete(GinkgoT().Context(), &externalSecret)
+			Expect(err).ToNot(HaveOccurred())
+		}
+	} else if !util.IsMissingAPIResourceError(err) {
 		Expect(err).ToNot(HaveOccurred())
 	}