Просмотр исходного кода

test(e2e): add webhook provider coverage with an in-process backend

spec.url accepts any address, so the suite serves the backend from inside its
own process and publishes it to the controller with a selector-less Service and
an EndpointSlice. No image, no addon and no credentials, so the leg runs on
every PR. Keeping the backend in-process is also what lets the push spec assert
the exact body and headers the controller sent instead of inferring them.

Covers reads via result.jsonPath, the templated url and headers including the
.<name>.<keyInSecret> form for spec.secrets, the external-secrets.io/type label
requirement on both store secrets and ntlm refs, PushSecret, and dataFrom.find
as unsupported. Only property-free entries from cases/common are wired up,
because GetSecret ignores remoteRef.property.

Fixes: external-secrets/external-secrets#6757
Signed-off-by: Alexander Chernov <alexander@chernov.it>
Alexander Chernov 1 месяц назад
Родитель
Сommit
0367178642

+ 3 - 2
e2e/README.md

@@ -109,8 +109,9 @@ aws:        groups=['aws'] -> AWS_OIDC_ROLE_ARN, AWS_SA_NAME, AWS_SA_NAMESPACE
 ```
 
 Which providers actually need external credentials: `fake`, `kubernetes`,
-`template`, `crd`, `vault`, `openbao`, `conjur`, and `infisical` run against
-in-cluster addons (or, for `crd` and `kubernetes`, the cluster's own API) and
+`template`, `crd`, `webhook`, `vault`, `openbao`, `conjur`, and `infisical` run
+against in-cluster addons (or, for `crd` and `kubernetes`, the cluster's own
+API, and for `webhook`, an HTTP backend the suite runs in its own process) and
 need none. The rest hit real APIs and are scoped to their group.
 
 The `generator` suite is split across two legs by label. The `generator` leg

+ 15 - 0
e2e/matrix.yaml

@@ -72,6 +72,21 @@ areas:
       - "e2e/suites/provider/cases/crd/**"
     enabled: true
 
+  # The webhook provider talks to an HTTP backend the suite runs inside its own
+  # test process (spec.url accepts any address), so it needs no credentials and
+  # no addon. Its own leg for the same reason as crd: attribution, and so a
+  # webhook break does not also fail the fake, kubernetes and template specs.
+  - name: webhook
+    suite: provider
+    labels: "webhook && !managed"
+    providers: [webhook]
+    secret_groups: []
+    needs_secrets: false
+    paths:
+      - "providers/v1/webhook/**"
+      - "e2e/suites/provider/cases/webhook/**"
+    enabled: true
+
   # Self-hosted secret backends, installed as in-cluster addons (no cloud
   # creds). One leg per provider so a broken addon (e.g. conjur) fails only its
   # own leg, not the others, and the leg name matches what it runs.

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

@@ -35,4 +35,5 @@ import (
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/secretserver"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/template"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/vault"
+	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/webhook"
 )

+ 443 - 0
e2e/suites/provider/cases/webhook/provider.go

@@ -0,0 +1,443 @@
+/*
+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 webhook exercises the webhook provider against an HTTP backend that
+// runs inside this test process. The provider's spec.url accepts any address,
+// so the backend needs no vendor account and no repo secrets, which keeps the
+// leg fork-safe and runnable on every PR.
+//
+// The backend is deliberately in-process rather than a deployed image: it
+// records every request it receives, so a PushSecret spec can assert the exact
+// body and headers the controller sent instead of inferring them from a status
+// or scraping pod logs.
+//
+// IMPORTANT: this only works when the suite itself runs inside the cluster,
+// which is what e2e/run.sh does (it launches the suite as a pod). Running the
+// suite from a workstation against a kind cluster leaves the Service with an
+// endpoint the controller cannot reach; setUpBackend fails early and says so.
+package webhook
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"os"
+	"strings"
+	"sync"
+
+	// nolint
+	. "github.com/onsi/ginkgo/v2"
+
+	// nolint
+	. "github.com/onsi/gomega"
+	corev1 "k8s.io/api/core/v1"
+	discoveryv1 "k8s.io/api/discovery/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/intstr"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+)
+
+const (
+	// backendBasePort is offset by the ginkgo process index. The suite runs with
+	// `ginkgo -p`, so several processes share one pod: each needs its own
+	// listener port, and gets its own in-memory store with it.
+	backendBasePort = 18080
+
+	// serviceName is created per test namespace so it is torn down with the
+	// namespace rather than leaking for the life of the suite.
+	serviceName = "webhook-e2e-backend"
+	portName    = "http"
+
+	// namespaceFile is how a process running in a pod learns its own namespace.
+	namespaceFile = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
+
+	// The provider reads every Secret through getStoreSecret, which requires
+	// this label on all of them, the auth ones included.
+	storeTypeLabel = "external-secrets.io/type"
+	storeTypeValue = "webhook"
+
+	authSecretName  = "webhook-e2e-auth"
+	authSecretKey   = "token"
+	authSecretValue = "e2e-secret-token"
+
+	// kvPath serves reads, pushes and deletes.
+	kvPath = "/kv/"
+
+	// The read and the push code paths template spec.url from disjoint variable
+	// sets: a read exposes .remoteRef.key, while a push and a delete expose
+	// .remoteRef.remoteKey. text/template runs with missingkey=default, so
+	// referencing the absent one renders the literal "<no value>" rather than an
+	// empty string, which means one url template cannot serve both. Specs pick
+	// the template that matches what they exercise.
+	readKeyTemplate = "{{ .remoteRef.key }}"
+	pushKeyTemplate = "{{ .remoteRef.remoteKey }}"
+)
+
+// recordedRequest is what the backend saw on the wire. Specs assert against
+// this, which is the reason the backend lives in-process.
+type recordedRequest struct {
+	Method string
+	Path   string
+	Header http.Header
+	Body   string
+}
+
+// backend is the in-process HTTP store. Its zero value is not usable; use
+// newBackend.
+type backend struct {
+	mu       sync.Mutex
+	values   map[string]string
+	requests []recordedRequest
+}
+
+func newBackend() *backend {
+	return &backend{values: map[string]string{}}
+}
+
+func (b *backend) set(key, value string) {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	b.values[key] = value
+}
+
+func (b *backend) delete(key string) {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	delete(b.values, key)
+}
+
+func (b *backend) value(key string) (string, bool) {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	v, ok := b.values[key]
+	return v, ok
+}
+
+// reset clears state between specs. Specs in one process share a backend, so
+// without this a key left behind by a failed spec would leak into the next.
+func (b *backend) reset() {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	b.values = map[string]string{}
+	b.requests = nil
+}
+
+func (b *backend) record(r recordedRequest) {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	b.requests = append(b.requests, r)
+}
+
+// requestsFor returns every recorded request whose path ends in key, oldest
+// first.
+//
+// Results are not guaranteed to belong only to the current spec. Namespace
+// deletion does not block, so a previous spec's ExternalSecret can still be
+// reconciling against the same listener for a few seconds. Match on the last
+// entry or filter by method; do not assert an exact count.
+func (b *backend) requestsFor(key string) []recordedRequest {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	var out []recordedRequest
+	for _, r := range b.requests {
+		if strings.HasSuffix(r.Path, "/"+key) {
+			out = append(out, r)
+		}
+	}
+	return out
+}
+
+// handler implements the read, push and delete verbs the provider issues.
+//
+// A read returns {"value": "<stored>"} and the store sets result.jsonPath to
+// $.value. That shape serves both provider entry points: GetSecret returns the
+// string as-is, and GetSecretMap re-parses a string result as JSON, so
+// dataFrom.extract works when the stored value is a JSON object.
+//
+// A miss must be 404 and nothing else: the provider maps 404 to NoSecretError,
+// which is what makes SecretExists report false instead of erroring, and what
+// makes a delete of an absent key succeed.
+func (b *backend) handler() http.Handler {
+	mux := http.NewServeMux()
+	mux.HandleFunc(kvPath, func(w http.ResponseWriter, r *http.Request) {
+		key := strings.TrimPrefix(r.URL.Path, kvPath)
+		body, _ := io.ReadAll(r.Body)
+		b.record(recordedRequest{
+			Method: r.Method,
+			Path:   r.URL.Path,
+			Header: r.Header.Clone(),
+			Body:   string(body),
+		})
+
+		switch r.Method {
+		case http.MethodGet:
+			value, ok := b.value(key)
+			if !ok {
+				w.WriteHeader(http.StatusNotFound)
+				return
+			}
+			w.Header().Set("Content-Type", "application/json")
+			_ = json.NewEncoder(w).Encode(map[string]string{"value": value})
+		case http.MethodPost, http.MethodPut:
+			b.set(key, string(body))
+			w.WriteHeader(http.StatusOK)
+		case http.MethodDelete:
+			if _, ok := b.value(key); !ok {
+				w.WriteHeader(http.StatusNotFound)
+				return
+			}
+			b.delete(key)
+			w.WriteHeader(http.StatusNoContent)
+		default:
+			w.WriteHeader(http.StatusMethodNotAllowed)
+		}
+	})
+	return mux
+}
+
+// addressType picks the EndpointSlice address family from the resolved address,
+// so a dual-stack or IPv6-first cluster does not fail API validation.
+func addressType(ip string) discoveryv1.AddressType {
+	if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() == nil {
+		return discoveryv1.AddressTypeIPv6
+	}
+	return discoveryv1.AddressTypeIPv4
+}
+
+// The listener is per process, so it is started once and shared by every spec
+// that process runs. The Service is per namespace and so is created per spec.
+var (
+	setUpOnce   sync.Once
+	sharedState *backend
+	sharedPort  int
+	sharedPodIP string
+	sharedSetUp error
+)
+
+// Provider wires the in-process backend to the framework's table tests.
+type Provider struct {
+	framework *framework.Framework
+	backend   *backend
+	baseURL   string
+}
+
+func NewProvider(f *framework.Framework) *Provider {
+	prov := &Provider{framework: f}
+	// Registered as BeforeEach rather than run here: this constructor executes
+	// during tree construction in every parallel process, including ones that
+	// will not run a single webhook spec.
+	BeforeEach(prov.BeforeEach)
+	return prov
+}
+
+func (p *Provider) BeforeEach() {
+	setUpOnce.Do(setUpBackend)
+	Expect(sharedSetUp).ToNot(HaveOccurred())
+
+	p.backend = sharedState
+	p.backend.reset()
+	p.exposeBackend()
+	p.CreateAuthSecret(authSecretName, true)
+	p.CreateStore()
+}
+
+// setUpBackend binds this process's port and starts serving. Errors are stored
+// rather than asserted so the failure surfaces inside a spec.
+func setUpBackend() {
+	sharedState = newBackend()
+
+	// Order matters: the namespace file is the only reliable in-a-pod signal, so
+	// check it before resolving an address. Hostname resolution succeeds off
+	// cluster too (to 127.0.1.1 on Debian, to a LAN address on macOS), which
+	// would otherwise publish an EndpointSlice the controller cannot use and
+	// leave every spec failing with an opaque connection error.
+	if err := assertRunningInCluster(); err != nil {
+		sharedSetUp = err
+		return
+	}
+
+	ip, err := podIP()
+	if err != nil {
+		sharedSetUp = err
+		return
+	}
+	sharedPodIP = ip
+	sharedPort = backendBasePort + GinkgoParallelProcess()
+
+	listener, err := net.Listen("tcp", fmt.Sprintf(":%d", sharedPort))
+	if err != nil {
+		sharedSetUp = fmt.Errorf("cannot listen on port %d: %w", sharedPort, err)
+		return
+	}
+	server := &http.Server{Handler: sharedState.handler()}
+	go func() {
+		defer GinkgoRecover()
+		if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
+			// The suite is tearing down; nothing left to assert against.
+			_, _ = fmt.Fprintf(GinkgoWriter, "backend stopped: %v\n", err)
+		}
+	}()
+}
+
+// podIP returns the address the controller will connect back to.
+func podIP() (string, error) {
+	host, err := os.Hostname()
+	if err != nil {
+		return "", fmt.Errorf("cannot determine hostname: %w", err)
+	}
+	addrs, err := net.LookupHost(host)
+	if err != nil || len(addrs) == 0 {
+		return "", fmt.Errorf("cannot resolve own address (%q): %w", host, err)
+	}
+	return addrs[0], nil
+}
+
+// assertRunningInCluster fails when the suite is not executing inside a pod.
+// The projected serviceaccount namespace file is the discriminator; hostname
+// resolution is not, because it succeeds off cluster and yields an address the
+// controller cannot route to.
+func assertRunningInCluster() error {
+	if _, err := os.Stat(namespaceFile); err != nil {
+		return fmt.Errorf(
+			"%s is absent, so this is not running in a pod: the webhook suite "+
+				"exposes an in-process backend to the controller and must run "+
+				"in-cluster, the way e2e/run.sh launches it: %w", namespaceFile, err)
+	}
+	return nil
+}
+
+// exposeBackend publishes this process's listener into the test namespace. The
+// Service carries no selector, and the EndpointSlice names the pod address
+// explicitly, so it resolves regardless of which namespace the suite pod runs
+// in and regardless of the labels run.sh happens to set on it.
+func (p *Provider) exposeBackend() {
+	ns := p.framework.Namespace.Name
+	port := int32(sharedPort)
+
+	svc := &corev1.Service{
+		ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: ns},
+		Spec: corev1.ServiceSpec{
+			Ports: []corev1.ServicePort{{
+				Name:       portName,
+				Port:       port,
+				TargetPort: intstr.FromInt32(port),
+				Protocol:   corev1.ProtocolTCP,
+			}},
+		},
+	}
+	Expect(p.framework.CRClient.Create(GinkgoT().Context(), svc)).To(Succeed())
+
+	slice := &discoveryv1.EndpointSlice{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      serviceName,
+			Namespace: ns,
+			Labels:    map[string]string{discoveryv1.LabelServiceName: serviceName},
+		},
+		AddressType: addressType(sharedPodIP),
+		Endpoints: []discoveryv1.Endpoint{{
+			Addresses:  []string{sharedPodIP},
+			Conditions: discoveryv1.EndpointConditions{Ready: new(true)},
+		}},
+		Ports: []discoveryv1.EndpointPort{{
+			Name:     new(portName),
+			Port:     new(port),
+			Protocol: new(corev1.ProtocolTCP),
+		}},
+	}
+	Expect(p.framework.CRClient.Create(GinkgoT().Context(), slice)).To(Succeed())
+
+	p.baseURL = fmt.Sprintf("http://%s.%s.svc.cluster.local:%d%s",
+		serviceName, ns, sharedPort, kvPath)
+}
+
+// CreateAuthSecret creates the Secret the store references from spec.secrets.
+// Pass labelled=false to build the same Secret without the
+// external-secrets.io/type label, which is how the negative specs prove the
+// provider refuses it.
+func (p *Provider) CreateAuthSecret(name string, labelled bool) {
+	secret := &corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: p.framework.Namespace.Name,
+		},
+		Data: map[string][]byte{authSecretKey: []byte(authSecretValue)},
+	}
+	if labelled {
+		secret.Labels = map[string]string{storeTypeLabel: storeTypeValue}
+	}
+	Expect(p.framework.CRClient.Create(GinkgoT().Context(), secret)).To(Succeed())
+}
+
+// CreateSecret implements framework.SecretStoreProvider by writing straight
+// into the backing map, so the shared table in cases/common applies here.
+func (p *Provider) CreateSecret(key string, val framework.SecretEntry) {
+	p.backend.set(key, val.Value)
+}
+
+func (p *Provider) DeleteSecret(key string) {
+	p.backend.delete(key)
+}
+
+// CreateStore installs the read-oriented SecretStore the specs sync through.
+//
+// spec.method is deliberately left unset. It is shared by the read and the push
+// path but its default differs per path: GET for a read, POST for a push, with
+// a delete always DELETE. Pinning it to GET here would silently turn every
+// push into a GET.
+func (p *Provider) CreateStore() {
+	By("creating a webhook secret store")
+	store := &esv1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      p.framework.Namespace.Name,
+			Namespace: p.framework.Namespace.Name,
+		},
+		Spec: p.storeSpec(authSecretName, readKeyTemplate),
+	}
+	Expect(p.framework.CRClient.Create(GinkgoT().Context(), store)).To(Succeed())
+}
+
+// storeSpec builds a webhook store whose url and X-Remote-Key header address
+// the remote key through keyTemplate, so callers choose between the read and
+// the push variable set. The negative specs reuse it to point the same store at
+// a differently-built Secret.
+func (p *Provider) storeSpec(secretName, keyTemplate string) esv1.SecretStoreSpec {
+	return esv1.SecretStoreSpec{
+		Provider: &esv1.SecretStoreProvider{
+			Webhook: &esv1.WebhookProvider{
+				URL: p.baseURL + keyTemplate,
+				Headers: map[string]string{
+					// Proves spec.secrets values are addressed as
+					// .<name>.<keyInSecret> in a header template.
+					"Authorization": "Bearer {{ .creds.token }}",
+					"X-Remote-Key":  keyTemplate,
+				},
+				Secrets: []esv1.WebhookSecret{{
+					Name: "creds",
+					SecretRef: esmeta.SecretKeySelector{
+						Name: secretName,
+						Key:  authSecretKey,
+					},
+				}},
+				Result: esv1.WebhookResult{JSONPath: "$.value"},
+			},
+		},
+	}
+}

+ 304 - 0
e2e/suites/provider/cases/webhook/webhook.go

@@ -0,0 +1,304 @@
+/*
+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 webhook
+
+import (
+	"net/http"
+	"time"
+
+	// nolint
+	. "github.com/onsi/ginkgo/v2"
+
+	// nolint
+	. "github.com/onsi/gomega"
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	"github.com/external-secrets/external-secrets-e2e/suites/provider/cases/common"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+)
+
+var _ = Describe("[webhook]", Label("webhook"), func() {
+	f := framework.New("eso-webhook")
+	prov := NewProvider(f)
+
+	// Only the shared entries the provider can actually satisfy are wired up.
+	// GetSecret ignores remoteRef.property entirely (it uses the ref for
+	// templating and then applies result.jsonPath), so every property-based
+	// entry in cases/common would fail here for a reason that is a documented
+	// provider limitation rather than a regression.
+	DescribeTable("sync secrets",
+		framework.TableFuncWithExternalSecret(f, prov),
+		Entry(common.SimpleDataSync(f)),
+		Entry(common.SyncWithoutTargetName(f)),
+		Entry(common.JSONDataFromSync(f)),
+		Entry(common.JSONDataFromRewrite(f)),
+		Entry(common.SSHKeySync(f)),
+		Entry(common.DeletionPolicyDelete(f)),
+	)
+
+	It("templates the url, headers and spec.secrets values", func() {
+		const key = "templating"
+		prov.CreateSecret(key, framework.SecretEntry{Value: "templated-value"})
+
+		createExternalSecret(f, "e2e-tpl", key)
+		// Type must be set: equalSecrets compares it, and ESO leaves the target
+		// to be defaulted to Opaque unless spec.target.template.type says
+		// otherwise, so an empty Type here would never converge.
+		_, err := f.WaitForSecretValue(f.Namespace.Name, framework.TargetSecretName,
+			&corev1.Secret{
+				Type: corev1.SecretTypeOpaque,
+				Data: map[string][]byte{"value": []byte("templated-value")},
+			})
+		Expect(err).ToNot(HaveOccurred())
+
+		// The request the controller actually sent proves the templating, which
+		// a green sync alone would not: the url carried the remote key, and the
+		// headers resolved both remoteRef and the .<name>.<keyInSecret> form
+		// that spec.secrets values are addressed by.
+		requests := prov.backend.requestsFor(key)
+		Expect(requests).ToNot(BeEmpty(), "backend recorded no request for %q", key)
+		got := requests[len(requests)-1]
+		Expect(got.Method).To(Equal(http.MethodGet))
+		Expect(got.Path).To(HaveSuffix(kvPath + key))
+		Expect(got.Header.Get("Authorization")).To(Equal("Bearer " + authSecretValue))
+		Expect(got.Header.Get("X-Remote-Key")).To(Equal(key))
+	})
+
+	It("refuses a store secret without the external-secrets.io/type label", func() {
+		const key = "unlabelled"
+		prov.CreateSecret(key, framework.SecretEntry{Value: "never-read"})
+
+		By("pointing the store at a Secret that carries no type label")
+		prov.CreateAuthSecret("webhook-e2e-unlabelled", false)
+		replaceStoreSecret(f, prov, "webhook-e2e-unlabelled")
+
+		createExternalSecret(f, "e2e-unlabelled", key)
+		expectNotReadyBecause(f, "e2e-unlabelled", "external-secrets.io/type")
+		expectNoTargetSecret(f)
+	})
+
+	It("refuses ntlm secret refs without the external-secrets.io/type label", func() {
+		const key = "ntlm"
+		prov.CreateSecret(key, framework.SecretEntry{Value: "never-read"})
+
+		By("adding ntlm auth whose Secrets carry no type label")
+		prov.CreateAuthSecret("webhook-e2e-ntlm", false)
+		store := &esv1.SecretStore{}
+		Expect(f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
+			Namespace: f.Namespace.Name, Name: f.Namespace.Name,
+		}, store)).To(Succeed())
+		base := store.DeepCopy()
+		ref := esmeta.SecretKeySelector{Name: "webhook-e2e-ntlm", Key: authSecretKey}
+		store.Spec.Provider.Webhook.Auth = &esv1.AuthorizationProtocol{
+			NTLM: &esv1.NTLMProtocol{UserName: ref, Password: ref},
+		}
+		Expect(f.CRClient.Patch(GinkgoT().Context(), store, client.MergeFrom(base))).To(Succeed())
+
+		createExternalSecret(f, "e2e-ntlm", key)
+		expectNotReadyBecause(f, "e2e-ntlm", "external-secrets.io/type")
+		expectNoTargetSecret(f)
+	})
+
+	It("pushes a secret and sends the templated body", func() {
+		// The remote key has no dash on purpose. With spec.body unset the
+		// provider builds the push body from the template
+		// "{{ .remoteRef.<remoteKey> }}", so a remote key that is not a valid
+		// Go template field name cannot be rendered. spec.body is set below,
+		// which avoids that, but keeping the key simple keeps the failure mode
+		// out of this spec.
+		const remoteKey = "pushed"
+		By("repointing the store at the push variable set and setting a body")
+		store := &esv1.SecretStore{}
+		Expect(f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
+			Namespace: f.Namespace.Name, Name: f.Namespace.Name,
+		}, store)).To(Succeed())
+		base := store.DeepCopy()
+		// The url and headers have to switch to .remoteRef.remoteKey: the push
+		// path never populates .remoteRef.key, and referencing it would render
+		// the literal "<no value>" into the path. See the note on
+		// readKeyTemplate.
+		store.Spec = prov.storeSpec(authSecretName, pushKeyTemplate)
+		store.Spec.Provider.Webhook.Body = `{"pushed":"{{ .remoteRef.` + remoteKey + ` }}"}`
+		Expect(f.CRClient.Patch(GinkgoT().Context(), store, client.MergeFrom(base))).To(Succeed())
+
+		source := &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "webhook-e2e-push-source",
+				Namespace: f.Namespace.Name,
+			},
+			Data: map[string][]byte{"secret-key": []byte("pushed-value")},
+		}
+		Expect(f.CRClient.Create(GinkgoT().Context(), source)).To(Succeed())
+
+		ps := &esv1alpha1.PushSecret{
+			ObjectMeta: metav1.ObjectMeta{Name: "e2e-ps", Namespace: f.Namespace.Name},
+			Spec: esv1alpha1.PushSecretSpec{
+				RefreshInterval: &metav1.Duration{Duration: 5 * time.Second},
+				SecretStoreRefs: []esv1alpha1.PushSecretStoreRef{{Name: f.Namespace.Name}},
+				Selector: esv1alpha1.PushSecretSelector{
+					Secret: &esv1alpha1.PushSecretSecret{Name: source.Name},
+				},
+				Data: []esv1alpha1.PushSecretData{{
+					Match: esv1alpha1.PushSecretMatch{
+						SecretKey: "secret-key",
+						RemoteRef: esv1alpha1.PushSecretRemoteRef{RemoteKey: remoteKey},
+					},
+				}},
+			},
+		}
+		Expect(f.CRClient.Create(GinkgoT().Context(), ps)).To(Succeed())
+
+		By("asserting what the backend received, not just that the push reported ready")
+		Eventually(func(g Gomega) {
+			requests := prov.backend.requestsFor(remoteKey)
+			var pushes []recordedRequest
+			for _, r := range requests {
+				if r.Method == http.MethodPost {
+					pushes = append(pushes, r)
+				}
+			}
+			g.Expect(pushes).ToNot(BeEmpty(), "backend recorded no push for %q", remoteKey)
+			last := pushes[len(pushes)-1]
+			g.Expect(last.Body).To(MatchJSON(`{"pushed":"pushed-value"}`))
+			g.Expect(last.Header.Get("Authorization")).To(Equal("Bearer " + authSecretValue))
+			g.Expect(last.Header.Get("X-Remote-Key")).To(Equal(remoteKey))
+		}, 2*time.Minute, 3*time.Second).Should(Succeed())
+
+		By("confirming the pushed value is then readable back through the store")
+		stored, ok := prov.backend.value(remoteKey)
+		Expect(ok).To(BeTrue())
+		Expect(stored).To(MatchJSON(`{"pushed":"pushed-value"}`))
+	})
+
+	It("reports dataFrom.find as unsupported", func() {
+		// GetAllSecrets is a stub returning errNotImplemented, so find can only
+		// be asserted as refused. Covering it keeps the gap explicit: if the
+		// provider ever implements it, this spec fails and has to be rewritten
+		// rather than quietly continuing to claim find does not work.
+		es := &esv1.ExternalSecret{
+			ObjectMeta: metav1.ObjectMeta{Name: "e2e-find", Namespace: f.Namespace.Name},
+			Spec: esv1.ExternalSecretSpec{
+				RefreshInterval: &metav1.Duration{Duration: 5 * time.Second},
+				SecretStoreRef: esv1.SecretStoreRef{
+					Name: f.Namespace.Name,
+					Kind: esv1.SecretStoreKind,
+				},
+				Target: esv1.ExternalSecretTarget{Name: framework.TargetSecretName},
+				DataFrom: []esv1.ExternalSecretDataFromRemoteRef{{
+					Find: &esv1.ExternalSecretFind{
+						Name: &esv1.FindName{RegExp: ".*"},
+					},
+				}},
+			},
+		}
+		Expect(f.CRClient.Create(GinkgoT().Context(), es)).To(Succeed())
+
+		expectNotReadyBecause(f, "e2e-find", "not implemented")
+		expectNoTargetSecret(f)
+	})
+})
+
+// createExternalSecret installs a single-key ExternalSecret reading key through
+// the store the provider created for this namespace.
+func createExternalSecret(f *framework.Framework, name, key string) {
+	es := &esv1.ExternalSecret{
+		ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: f.Namespace.Name},
+		Spec: esv1.ExternalSecretSpec{
+			RefreshInterval: &metav1.Duration{Duration: 5 * time.Second},
+			SecretStoreRef: esv1.SecretStoreRef{
+				Name: f.Namespace.Name,
+				Kind: esv1.SecretStoreKind,
+			},
+			Target: esv1.ExternalSecretTarget{Name: framework.TargetSecretName},
+			Data: []esv1.ExternalSecretData{{
+				SecretKey: "value",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: key},
+			}},
+		},
+	}
+	Expect(f.CRClient.Create(GinkgoT().Context(), es)).To(Succeed())
+}
+
+// replaceStoreSecret repoints the store's spec.secrets at another Secret,
+// keeping everything else identical.
+func replaceStoreSecret(f *framework.Framework, prov *Provider, secretName string) {
+	store := &esv1.SecretStore{}
+	Expect(f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
+		Namespace: f.Namespace.Name, Name: f.Namespace.Name,
+	}, store)).To(Succeed())
+	base := store.DeepCopy()
+	store.Spec = prov.storeSpec(secretName, readKeyTemplate)
+	Expect(f.CRClient.Patch(GinkgoT().Context(), store, client.MergeFrom(base))).To(Succeed())
+}
+
+// expectNotReadyBecause waits for the ExternalSecret to report a false Ready
+// condition AND for the reason it failed to mention want.
+//
+// Checking why it failed is the point: asserting only that it never became
+// ready would also pass on a typo in the fixture or a missing store, which is
+// how a suite ends up green about the wrong thing.
+//
+// The reason has to come from the Warning Event, not the condition. The
+// controller sets a fixed condition message ("could not get secret data from
+// provider", see markAsFailed) and routes the wrapped provider error to an
+// Event, so the condition message is identical for every provider failure and
+// asserting on it would be vacuous.
+func expectNotReadyBecause(f *framework.Framework, name, want string) {
+	Eventually(func(g Gomega) {
+		es := &esv1.ExternalSecret{}
+		g.Expect(f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
+			Namespace: f.Namespace.Name, Name: name,
+		}, es)).To(Succeed())
+		var ready *esv1.ExternalSecretStatusCondition
+		for i := range es.Status.Conditions {
+			if es.Status.Conditions[i].Type == esv1.ExternalSecretReady {
+				ready = &es.Status.Conditions[i]
+			}
+		}
+		g.Expect(ready).ToNot(BeNil(), "expected a Ready condition")
+		g.Expect(ready.Status).To(Equal(corev1.ConditionFalse))
+
+		events, err := f.KubeClientSet.CoreV1().Events(f.Namespace.Name).List(
+			GinkgoT().Context(), metav1.ListOptions{
+				FieldSelector: "involvedObject.name=" + name +
+					",involvedObject.kind=ExternalSecret",
+			})
+		g.Expect(err).ToNot(HaveOccurred())
+		var messages []string
+		for _, ev := range events.Items {
+			messages = append(messages, ev.Message)
+		}
+		g.Expect(messages).To(ContainElement(ContainSubstring(want)),
+			"no event explaining the refusal mentioned %q", want)
+	}, 2*time.Minute, 3*time.Second).Should(Succeed())
+}
+
+// expectNoTargetSecret asserts the refused read produced no Secret at all.
+func expectNoTargetSecret(f *framework.Framework) {
+	Consistently(func(g Gomega) {
+		err := f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
+			Namespace: f.Namespace.Name, Name: framework.TargetSecretName,
+		}, &corev1.Secret{})
+		g.Expect(apierrors.IsNotFound(err)).To(BeTrue())
+	}, 15*time.Second, 3*time.Second).Should(Succeed())
+}