diff --git a/api/v1alpha1/strategy_match_types.go b/api/v1alpha1/strategy_match_types.go index e3b599f..362067e 100644 --- a/api/v1alpha1/strategy_match_types.go +++ b/api/v1alpha1/strategy_match_types.go @@ -17,5 +17,21 @@ const ( ConditionOperationNotEqual ConditionOperation = "NotEqual" ConditionOperationContains ConditionOperation = "Contains" ConditionOperationNotContains ConditionOperation = "NotContains" - ConditionOperationIn ConditionOperation = "RegularExpression" + // ConditionOperationIn matches when the value found at Path matches the + // regular expression given in Value. Named "In" for historical reasons; + // ConditionOperationRegularExpression is provided as a clearer alias. + ConditionOperationIn ConditionOperation = "RegularExpression" + // ConditionOperationContainedBy matches when the value found at Path is a + // substring of Value - the inverse direction of Contains. This is what + // lets a MatchStrategy correlate a destination's own configured key + // (e.g. an ExternalSecret's spec.dataFrom[].extract.key) against a + // broader identifier surfaced by a notification source (e.g. an AWS + // Secrets Manager ARN), where the destination's key is a substring of, + // rather than equal to, the identifier on the incoming event. + ConditionOperationContainedBy ConditionOperation = "ContainedBy" + // ConditionOperationNotContainedBy is the negation of ConditionOperationContainedBy. + ConditionOperationNotContainedBy ConditionOperation = "NotContainedBy" ) + +// ConditionOperationRegularExpression is a clearer alias for ConditionOperationIn. +const ConditionOperationRegularExpression = ConditionOperationIn diff --git a/docs/reference/strategies.md b/docs/reference/strategies.md index 6c2bb31..3458fca 100644 --- a/docs/reference/strategies.md +++ b/docs/reference/strategies.md @@ -46,17 +46,23 @@ destinationsToWatch: ## Match Strategy -The Match Strategy controls how the Reloader determines whether a destination is affected by a given secret event. It evaluates a JSON path on the destination object against one or more conditions. +By default, a destination decides for itself whether it's affected by an event: an `ExternalSecret`, for example, is considered affected if the event's identifier exactly matches one of its configured remote keys. A Match Strategy replaces that default check with your own: it evaluates a JSON path on the destination object against one or more conditions, so you can match on a different field, a substring, or a regular expression instead. + +A condition's `value` is rendered as a Go template before comparison, with the event bound to the template root, so you can reference the specific event being processed - for example `{{ .SecretIdentifier }}`. Available fields are `SecretIdentifier`, `RotationTimestamp`, `TriggerSource`, and `Namespace`. A `value` with no template actions is used as a literal string. + +If `path` resolves to multiple values (for example, a path containing `[*]`), the destination matches if *any* of those values satisfies *all* of the given conditions. ### Condition Operations -| Operation | Description | -|---------------------|------------------------------------------------| -| `Equal` | Exact value match. | -| `NotEqual` | Value does not match. | -| `Contains` | Value contains the given substring. | -| `NotContains` | Value does not contain the given substring. | -| `RegularExpression` | Value matches the given regular expression. | +| Operation | Description | +|---------------------|----------------------------------------------| +| `Equal` | Exact value match. | +| `NotEqual` | Value does not match. | +| `Contains` | Value contains the given substring. | +| `NotContains` | Value does not contain the given substring. | +| `ContainedBy` | Value is a substring of the given value. | +| `NotContainedBy` | Value is not a substring of the given value.| +| `RegularExpression` | Value matches the given regular expression. | ### Example: Custom Match Path @@ -88,6 +94,23 @@ destinationsToWatch: operation: RegularExpression ``` +### Example: Matching a Friendly Name Against an ARN + +Some sources - such as `AwsSqs` reading CloudTrail's `PutSecretValue` events - surface a secret's full ARN as the event identifier, while an `ExternalSecret` conventionally references that same secret by its friendly name. `ContainedBy`, combined with templating, lets the friendly name match against the ARN it's embedded in: + +```yaml +destinationsToWatch: + - type: ExternalSecret + externalSecret: + labelSelectors: + matchLabels: {} + matchStrategy: + path: "spec.dataFrom[*].extract.key" + conditions: + - value: "{{ .SecretIdentifier }}" + operation: ContainedBy +``` + ## Wait Strategy The Wait Strategy controls how the Reloader waits between reconciling multiple destination objects. This is useful to avoid overwhelming the cluster with simultaneous rollouts. diff --git a/internal/controller/reloader_controller_test.go b/internal/controller/reloader_controller_test.go index 57e04c7..8ac435e 100644 --- a/internal/controller/reloader_controller_test.go +++ b/internal/controller/reloader_controller_test.go @@ -314,6 +314,66 @@ var _ = Describe("Reloader Controller", func() { assertAnnotationsWithSource(fakeClient, esName, "2024-09-19T12:00:00Z", "KubernetesConfigMap") }) }) + + Context("When a destination configures a MatchStrategy", func() { + It("should annotate an ExternalSecret referenced by friendly name when the event identifier is the full AWS Secrets Manager ARN", func() { + esName := "test-external-secret-matchstrategy" + friendlyName := "platform/ai-gateway/service-secrets" + arn := "arn:aws:secretsmanager:us-east-1:051826739313:secret:platform/ai-gateway/service-secrets-78YXTj" + + // Add a second destination entry with its own MatchStrategy, so + // it doesn't affect the default References() behavior exercised + // by the other Contexts in this suite. + updatedConfig := &esov1.Config{} + Expect(fakeClient.Get(ctx, types.NamespacedName{Name: config.Name, Namespace: config.Namespace}, updatedConfig)).To(Succeed()) + updatedConfig.Spec.DestinationsToWatch = append(updatedConfig.Spec.DestinationsToWatch, esov1.DestinationToWatch{ + Type: "ExternalSecret", + ExternalSecret: &esov1.ExternalSecretDestination{ + Names: []string{esName}, + }, + MatchStrategy: &esov1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []esov1.Condition{ + {Value: "{{ .SecretIdentifier }}", Operation: esov1.ConditionOperationContainedBy}, + }, + }, + }) + Expect(fakeClient.Update(ctx, updatedConfig)).To(Succeed()) + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: config.Name, Namespace: config.Namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + externalSecret = &esv1.ExternalSecret{ + ObjectMeta: metav1.ObjectMeta{ + Name: esName, + Namespace: "default", + }, + Spec: esv1.ExternalSecretSpec{ + SecretStoreRef: esv1.SecretStoreRef{ + Name: "my-secret-store", + Kind: "SecretStore", + }, + DataFrom: []esv1.ExternalSecretDataFromRemoteRef{ + { + Extract: &esv1.ExternalSecretDataRemoteRef{ + Key: friendlyName, + }, + }, + }, + }, + } + Expect(fakeClient.Create(context.Background(), externalSecret)).To(Succeed()) + + eventChan <- events.SecretRotationEvent{ + SecretIdentifier: arn, + RotationTimestamp: "2026-07-23T17:25:37Z", + TriggerSource: "aws-secretsmanager", + } + + assertAnnotationsWithSource(fakeClient, esName, "2026-07-23T17:25:37Z", "aws-secretsmanager") + }) + }) }) func assertAnnotations(fakeClient client.Client, secretName string) { diff --git a/internal/handler/processor.go b/internal/handler/processor.go index cdca3cd..4ff14b7 100644 --- a/internal/handler/processor.go +++ b/internal/handler/processor.go @@ -10,6 +10,7 @@ import ( esov1alpha1 "github.com/external-secrets/reloader/api/v1alpha1" "github.com/external-secrets/reloader/internal/events" "github.com/external-secrets/reloader/internal/handler/schema" + "github.com/external-secrets/reloader/internal/matchstrategy" ) type EventHandler struct { @@ -40,20 +41,28 @@ func (h *EventHandler) HandleEvent(ctx context.Context, event events.SecretRotat continue } h := prov.NewHandler(ctx, h.client, watchCriteria) - // Mutate Handler for different Update and Match Strategies + // Mutate Handler for different Update Strategies if watchCriteria.UpdateStrategy != nil { logger.Info("Optional Update strategies are not implemented", "UpdateStrategy", watchCriteria.UpdateStrategy) } - if watchCriteria.MatchStrategy != nil { - logger.Info("Optional Match strategies are not implemented", "MatchStrategy", watchCriteria.MatchStrategy) - } objs, err := h.Filter(&watchCriteria, event) if err != nil { return fmt.Errorf("failed to filter objects:%w", err) } // Use Handler methods to figure out and apply objects for _, obj := range objs { - isReferenced, err := h.References(obj, event.SecretIdentifier) + // When a MatchStrategy is configured, it replaces the destination + // type's built-in References() check entirely, so callers can + // correlate a destination against the event on their own terms + // (e.g. a custom path, or a comparison the built-in check doesn't + // support). + var isReferenced bool + var err error + if watchCriteria.MatchStrategy != nil { + isReferenced, err = matchstrategy.Evaluate(watchCriteria.MatchStrategy, obj, event) + } else { + isReferenced, err = h.References(obj, event.SecretIdentifier) + } if err != nil { // This error means something went wrong on a reference check - which is typically very bad logger.Error(err, "failed to check if object is referenced", "name", obj.GetName(), "namespace", obj.GetNamespace(), "type", watchCriteria.Type) diff --git a/internal/handler/processor_test.go b/internal/handler/processor_test.go new file mode 100644 index 0000000..92e10d8 --- /dev/null +++ b/internal/handler/processor_test.go @@ -0,0 +1,218 @@ +package handler + +import ( + "context" + "testing" + + esov1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + esreloaderv1alpha1 "github.com/external-secrets/reloader/api/v1alpha1" + "github.com/external-secrets/reloader/internal/events" +) + +// These tests exercise HandleEvent's wiring of MatchStrategy: when a +// destination configures one, it must replace the destination type's +// built-in References() check, and any error evaluating it must propagate +// out of HandleEvent rather than being swallowed. + +func newFakeClientWithExternalSecret(t *testing.T, es *esov1.ExternalSecret) client.Client { + t.Helper() + scheme := runtime.NewScheme() + if err := esov1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + builder := fake.NewClientBuilder().WithScheme(scheme) + if es != nil { + builder = builder.WithObjects(es) + } + return builder.Build() +} + +func getAnnotations(t *testing.T, c client.Client, name, namespace string) map[string]string { + t.Helper() + es := &esov1.ExternalSecret{} + if err := c.Get(context.Background(), types.NamespacedName{Name: name, Namespace: namespace}, es); err != nil { + t.Fatalf("Get: %v", err) + } + return es.GetAnnotations() +} + +func TestHandleEvent_MatchStrategyReplacesDefaultReferences(t *testing.T) { + es := &esov1.ExternalSecret{ + ObjectMeta: metav1.ObjectMeta{Name: "ai-gateway", Namespace: "default"}, + Spec: esov1.ExternalSecretSpec{ + DataFrom: []esov1.ExternalSecretDataFromRemoteRef{ + {Extract: &esov1.ExternalSecretDataRemoteRef{Key: "platform/ai-gateway/service-secrets"}}, + }, + }, + } + c := newFakeClientWithExternalSecret(t, es) + + eh := NewEventHandler(c) + eh.UpdateDestinationsToWatch([]esreloaderv1alpha1.DestinationToWatch{ + { + Type: "ExternalSecret", + ExternalSecret: &esreloaderv1alpha1.ExternalSecretDestination{Names: []string{"ai-gateway"}}, + MatchStrategy: &esreloaderv1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []esreloaderv1alpha1.Condition{ + {Value: "{{ .SecretIdentifier }}", Operation: esreloaderv1alpha1.ConditionOperationContainedBy}, + }, + }, + }, + }) + + // The default References() check would strictly compare this ARN + // against dataFrom.extract.key and never match. The MatchStrategy + // above must be the thing that decides this, and it should match. + event := events.SecretRotationEvent{ + SecretIdentifier: "arn:aws:secretsmanager:us-east-1:051826739313:secret:platform/ai-gateway/service-secrets-78YXTj", + RotationTimestamp: "2026-07-23T17:25:37Z", + TriggerSource: "aws-secretsmanager", + } + if err := eh.HandleEvent(context.Background(), event); err != nil { + t.Fatalf("HandleEvent: %v", err) + } + + annotations := getAnnotations(t, c, "ai-gateway", "default") + if annotations["reloader/last-rotated"] != "2026-07-23T17:25:37Z" { + t.Errorf("expected the ExternalSecret to be annotated via the MatchStrategy match, got annotations: %v", annotations) + } +} + +func TestHandleEvent_MatchStrategyNoMatchLeavesObjectUntouched(t *testing.T) { + es := &esov1.ExternalSecret{ + ObjectMeta: metav1.ObjectMeta{Name: "ai-gateway", Namespace: "default"}, + Spec: esov1.ExternalSecretSpec{ + DataFrom: []esov1.ExternalSecretDataFromRemoteRef{ + {Extract: &esov1.ExternalSecretDataRemoteRef{Key: "platform/ai-gateway/service-secrets"}}, + }, + }, + } + c := newFakeClientWithExternalSecret(t, es) + + eh := NewEventHandler(c) + eh.UpdateDestinationsToWatch([]esreloaderv1alpha1.DestinationToWatch{ + { + Type: "ExternalSecret", + ExternalSecret: &esreloaderv1alpha1.ExternalSecretDestination{Names: []string{"ai-gateway"}}, + MatchStrategy: &esreloaderv1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []esreloaderv1alpha1.Condition{ + {Value: "{{ .SecretIdentifier }}", Operation: esreloaderv1alpha1.ConditionOperationContainedBy}, + }, + }, + }, + }) + + event := events.SecretRotationEvent{ + SecretIdentifier: "arn:aws:secretsmanager:us-east-1:051826739313:secret:platform/unrelated-service/service-secrets-78YXTj", + RotationTimestamp: "2026-07-23T17:25:37Z", + } + if err := eh.HandleEvent(context.Background(), event); err != nil { + t.Fatalf("HandleEvent: %v", err) + } + + annotations := getAnnotations(t, c, "ai-gateway", "default") + if annotations != nil { + t.Errorf("expected no annotations for an unrelated secret, got: %v", annotations) + } +} + +func TestHandleEvent_MatchStrategyErrorPropagates(t *testing.T) { + es := &esov1.ExternalSecret{ + ObjectMeta: metav1.ObjectMeta{Name: "ai-gateway", Namespace: "default"}, + Spec: esov1.ExternalSecretSpec{ + DataFrom: []esov1.ExternalSecretDataFromRemoteRef{ + {Extract: &esov1.ExternalSecretDataRemoteRef{Key: "platform/ai-gateway/service-secrets"}}, + }, + }, + } + c := newFakeClientWithExternalSecret(t, es) + + eh := NewEventHandler(c) + eh.UpdateDestinationsToWatch([]esreloaderv1alpha1.DestinationToWatch{ + { + Type: "ExternalSecret", + ExternalSecret: &esreloaderv1alpha1.ExternalSecretDestination{Names: []string{"ai-gateway"}}, + MatchStrategy: &esreloaderv1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []esreloaderv1alpha1.Condition{ + // Invalid regular expression: this is a configuration + // error and must be surfaced, not silently swallowed + // into a "no match". + {Value: "(", Operation: esreloaderv1alpha1.ConditionOperationIn}, + }, + }, + }, + }) + + event := events.SecretRotationEvent{SecretIdentifier: "anything"} + if err := eh.HandleEvent(context.Background(), event); err == nil { + t.Fatal("expected HandleEvent to return an error when MatchStrategy evaluation fails") + } + + // And, since evaluation errored rather than resolving to "no match", the + // object must not have been touched either. + annotations := getAnnotations(t, c, "ai-gateway", "default") + if annotations != nil { + t.Errorf("expected no annotations when MatchStrategy evaluation errors, got: %v", annotations) + } +} + +func TestHandleEvent_NoMatchStrategyFallsBackToDefaultReferences(t *testing.T) { + es := &esov1.ExternalSecret{ + ObjectMeta: metav1.ObjectMeta{Name: "ai-gateway", Namespace: "default"}, + Spec: esov1.ExternalSecretSpec{ + DataFrom: []esov1.ExternalSecretDataFromRemoteRef{ + {Extract: &esov1.ExternalSecretDataRemoteRef{Key: "platform/ai-gateway/service-secrets"}}, + }, + }, + } + c := newFakeClientWithExternalSecret(t, es) + + eh := NewEventHandler(c) + eh.UpdateDestinationsToWatch([]esreloaderv1alpha1.DestinationToWatch{ + { + Type: "ExternalSecret", + ExternalSecret: &esreloaderv1alpha1.ExternalSecretDestination{Names: []string{"ai-gateway"}}, + // No MatchStrategy: falls back to the default References() + // check, which does a strict equality match. + }, + }) + + // Exact match against dataFrom.extract.key should still work via the + // default (unchanged) behavior. + event := events.SecretRotationEvent{ + SecretIdentifier: "platform/ai-gateway/service-secrets", + RotationTimestamp: "2026-07-23T17:25:37Z", + } + if err := eh.HandleEvent(context.Background(), event); err != nil { + t.Fatalf("HandleEvent: %v", err) + } + + annotations := getAnnotations(t, c, "ai-gateway", "default") + if annotations["reloader/last-rotated"] != "2026-07-23T17:25:37Z" { + t.Errorf("expected default References() matching to still work, got annotations: %v", annotations) + } +} + +// TestHandleEvent_UnknownProviderIsSkipped verifies HandleEvent doesn't fail +// the whole event when a destination type has no registered provider - it +// should just skip that destination. +func TestHandleEvent_UnknownProviderIsSkipped(t *testing.T) { + c := newFakeClientWithExternalSecret(t, nil) + eh := NewEventHandler(c) + eh.UpdateDestinationsToWatch([]esreloaderv1alpha1.DestinationToWatch{ + {Type: "NotARealDestinationType"}, + }) + + if err := eh.HandleEvent(context.Background(), events.SecretRotationEvent{SecretIdentifier: "x"}); err != nil { + t.Fatalf("HandleEvent: %v", err) + } +} diff --git a/internal/matchstrategy/matchstrategy.go b/internal/matchstrategy/matchstrategy.go new file mode 100644 index 0000000..6e2e6cd --- /dev/null +++ b/internal/matchstrategy/matchstrategy.go @@ -0,0 +1,198 @@ +// Package matchstrategy implements the Config CRD's optional MatchStrategy, +// which lets a destinationsToWatch entry decide for itself whether a given +// destination object is affected by an incoming SecretRotationEvent, instead +// of relying on the built-in per-destination-type References() check. +package matchstrategy + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "regexp" + "strings" + "text/template" + + "k8s.io/client-go/util/jsonpath" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/external-secrets/reloader/api/v1alpha1" + "github.com/external-secrets/reloader/internal/events" +) + +// Evaluate reports whether obj is considered a match under ms, given the +// event currently being processed. +// +// ms.Path is evaluated against obj using Kubernetes JSONPath syntax (the +// same syntax `kubectl get -o jsonpath` uses), without the surrounding curly +// braces - e.g. "spec.dataFrom[*].extract.key". Every value found at that +// path is checked against every condition in ms.Conditions; obj matches if +// at least one found value satisfies all of the conditions. +// +// Each condition's Value is rendered as a Go template before comparison, +// with the event bound as the template's root, so conditions can reference +// data from the specific event being processed - for example: +// +// conditions: +// - value: "{{ .SecretIdentifier }}" +// operation: ContainedBy +// +// Available template fields mirror events.SecretRotationEvent: +// .SecretIdentifier, .RotationTimestamp, .TriggerSource, .Namespace. +func Evaluate(ms *v1alpha1.MatchStrategy, obj client.Object, event events.SecretRotationEvent) (bool, error) { + if ms == nil { + return false, fmt.Errorf("matchStrategy must not be nil") + } + if ms.Path == "" { + return false, fmt.Errorf("matchStrategy.path must not be empty") + } + if len(ms.Conditions) == 0 { + return false, fmt.Errorf("matchStrategy.conditions must not be empty") + } + + values, err := valuesAtPath(obj, ms.Path) + if err != nil { + return false, fmt.Errorf("failed to evaluate matchStrategy.path %q: %w", ms.Path, err) + } + + conditions := make([]renderedCondition, 0, len(ms.Conditions)) + for _, c := range ms.Conditions { + rendered, err := renderConditionValue(c.Value, event) + if err != nil { + return false, fmt.Errorf("failed to render matchStrategy condition value %q: %w", c.Value, err) + } + conditions = append(conditions, renderedCondition{operation: c.Operation, value: rendered}) + } + + for _, v := range values { + matched, err := allConditionsMatch(v, conditions) + if err != nil { + return false, fmt.Errorf("failed to evaluate matchStrategy.conditions against %q: %w", v, err) + } + if matched { + return true, nil + } + } + return false, nil +} + +type renderedCondition struct { + operation v1alpha1.ConditionOperation + value string +} + +// allConditionsMatch reports whether fieldValue satisfies every condition. +// A malformed condition (e.g. an invalid regular expression, or an unknown +// operation) is a configuration error and is returned as such rather than +// silently treated as "no match", so it surfaces to whoever's watching the +// controller's logs instead of just quietly never firing. +func allConditionsMatch(fieldValue string, conditions []renderedCondition) (bool, error) { + for _, c := range conditions { + matched, err := evaluateCondition(fieldValue, c) + if err != nil { + return false, err + } + if !matched { + return false, nil + } + } + return true, nil +} + +func evaluateCondition(fieldValue string, c renderedCondition) (bool, error) { + switch c.operation { + case v1alpha1.ConditionOperationEqual: + return fieldValue == c.value, nil + case v1alpha1.ConditionOperationNotEqual: + return fieldValue != c.value, nil + case v1alpha1.ConditionOperationContains: + return strings.Contains(fieldValue, c.value), nil + case v1alpha1.ConditionOperationNotContains: + return !strings.Contains(fieldValue, c.value), nil + case v1alpha1.ConditionOperationContainedBy: + return strings.Contains(c.value, fieldValue), nil + case v1alpha1.ConditionOperationNotContainedBy: + return !strings.Contains(c.value, fieldValue), nil + case v1alpha1.ConditionOperationIn: // aka ConditionOperationRegularExpression + re, err := regexp.Compile(c.value) + if err != nil { + return false, fmt.Errorf("invalid regular expression %q: %w", c.value, err) + } + return re.MatchString(fieldValue), nil + default: + return false, fmt.Errorf("unsupported condition operation %q", c.operation) + } +} + +// renderConditionValue renders a condition's Value field as a Go template, +// with event bound as the root of the template so authors can reference its +// exported fields directly (e.g. "{{ .SecretIdentifier }}"). Values with no +// template actions are returned unchanged. +func renderConditionValue(value string, event events.SecretRotationEvent) (string, error) { + tpl, err := template.New("matchStrategyCondition").Option("missingkey=error").Parse(value) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err := tpl.Execute(&buf, event); err != nil { + return "", err + } + return buf.String(), nil +} + +// valuesAtPath evaluates a Kubernetes-style JSONPath expression (without the +// surrounding curly braces) against obj and returns every value found, +// stringified for comparison. A path that resolves to nothing (e.g. an +// unset optional field) yields an empty, non-error result. +func valuesAtPath(obj client.Object, path string) ([]string, error) { + raw, err := json.Marshal(obj) + if err != nil { + return nil, fmt.Errorf("failed to marshal object: %w", err) + } + var data any + if err := json.Unmarshal(raw, &data); err != nil { + return nil, fmt.Errorf("failed to unmarshal object: %w", err) + } + + jp := jsonpath.New("matchStrategy").AllowMissingKeys(true) + if err := jp.Parse(toJSONPathTemplate(path)); err != nil { + return nil, fmt.Errorf("invalid path: %w", err) + } + + results, err := jp.FindResults(data) + if err != nil { + return nil, err + } + + values := make([]string, 0) + for _, set := range results { + for _, v := range set { + values = append(values, stringify(v)) + } + } + return values, nil +} + +// toJSONPathTemplate turns a bare path such as "spec.dataFrom[*].extract.key" +// (the format used throughout this project's Config CRD and documentation) +// into the "{.spec.dataFrom[*].extract.key}" syntax client-go's +// jsonpath.Parse expects. +func toJSONPathTemplate(path string) string { + return fmt.Sprintf("{.%s}", strings.TrimPrefix(path, ".")) +} + +func stringify(v reflect.Value) string { + for v.Kind() == reflect.Interface || v.Kind() == reflect.Ptr { + if v.IsNil() { + return "" + } + v = v.Elem() + } + if v.Kind() == reflect.String { + return v.String() + } + if !v.IsValid() { + return "" + } + return fmt.Sprintf("%v", v.Interface()) +} diff --git a/internal/matchstrategy/matchstrategy_test.go b/internal/matchstrategy/matchstrategy_test.go new file mode 100644 index 0000000..dc9ca37 --- /dev/null +++ b/internal/matchstrategy/matchstrategy_test.go @@ -0,0 +1,645 @@ +package matchstrategy + +import ( + "reflect" + "testing" + + esov1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/external-secrets/reloader/api/v1alpha1" + "github.com/external-secrets/reloader/internal/events" +) + +func externalSecretWithExtractKey(key string) *esov1.ExternalSecret { + return &esov1.ExternalSecret{ + ObjectMeta: metav1.ObjectMeta{Name: "ai-gateway", Namespace: "ai-gateway"}, + Spec: esov1.ExternalSecretSpec{ + DataFrom: []esov1.ExternalSecretDataFromRemoteRef{ + {Extract: &esov1.ExternalSecretDataRemoteRef{Key: key}}, + }, + }, + } +} + +func externalSecretWithFindRegexp(re string) *esov1.ExternalSecret { + return &esov1.ExternalSecret{ + ObjectMeta: metav1.ObjectMeta{Name: "es", Namespace: "default"}, + Spec: esov1.ExternalSecretSpec{ + DataFrom: []esov1.ExternalSecretDataFromRemoteRef{ + {Find: &esov1.ExternalSecretFind{Name: &esov1.FindName{RegExp: re}}}, + }, + }, + } +} + +// TestEvaluate_ContainedBy_MatchesAWSARN verifies the motivating use case: +// an ExternalSecret referencing a secret by its AWS Secrets Manager friendly +// name is matched against an event whose SecretIdentifier is the full ARN +// (as delivered by the AwsSqs source), using ContainedBy plus templating. +func TestEvaluate_ContainedBy_MatchesAWSARN(t *testing.T) { + es := externalSecretWithExtractKey("platform/ai-gateway/service-secrets") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "{{ .SecretIdentifier }}", Operation: v1alpha1.ConditionOperationContainedBy}, + }, + } + event := events.SecretRotationEvent{ + SecretIdentifier: "arn:aws:secretsmanager:us-east-1:051826739313:secret:platform/ai-gateway/service-secrets-78YXTj", + } + + matched, err := Evaluate(ms, es, event) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if !matched { + t.Error("expected ContainedBy match against the ARN's embedded friendly name") + } +} + +func TestEvaluate_ContainedBy_NoMatch(t *testing.T) { + es := externalSecretWithExtractKey("platform/other-service/service-secrets") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "{{ .SecretIdentifier }}", Operation: v1alpha1.ConditionOperationContainedBy}, + }, + } + event := events.SecretRotationEvent{ + SecretIdentifier: "arn:aws:secretsmanager:us-east-1:051826739313:secret:platform/ai-gateway/service-secrets-78YXTj", + } + + matched, err := Evaluate(ms, es, event) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if matched { + t.Error("expected no match for an unrelated secret key") + } +} + +func TestEvaluate_Equal(t *testing.T) { + es := externalSecretWithExtractKey("my-secret") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "my-secret", Operation: v1alpha1.ConditionOperationEqual}, + }, + } + matched, err := Evaluate(ms, es, events.SecretRotationEvent{}) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if !matched { + t.Error("expected exact match") + } +} + +func TestEvaluate_NotEqual(t *testing.T) { + es := externalSecretWithExtractKey("my-secret") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "other-secret", Operation: v1alpha1.ConditionOperationNotEqual}, + }, + } + matched, err := Evaluate(ms, es, events.SecretRotationEvent{}) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if !matched { + t.Error("expected NotEqual to match when values differ") + } +} + +func TestEvaluate_Contains(t *testing.T) { + es := externalSecretWithExtractKey("platform/ai-gateway/service-secrets") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "ai-gateway", Operation: v1alpha1.ConditionOperationContains}, + }, + } + matched, err := Evaluate(ms, es, events.SecretRotationEvent{}) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if !matched { + t.Error("expected Contains to match a substring of the field value") + } +} + +func TestEvaluate_NotContains(t *testing.T) { + es := externalSecretWithExtractKey("platform/ai-gateway/service-secrets") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "jellyfish", Operation: v1alpha1.ConditionOperationNotContains}, + }, + } + matched, err := Evaluate(ms, es, events.SecretRotationEvent{}) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if !matched { + t.Error("expected NotContains to match when the substring is absent") + } +} + +// TestEvaluate_RegularExpression mirrors the documented example: an +// ExternalSecret using dataFrom.find.name.regexp, matched via +// RegularExpression against a literal condition value. +func TestEvaluate_RegularExpression(t *testing.T) { + es := externalSecretWithFindRegexp("prod/.*") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].find.name.regexp", + Conditions: []v1alpha1.Condition{ + {Value: "prod/.*", Operation: v1alpha1.ConditionOperationIn}, + }, + } + matched, err := Evaluate(ms, es, events.SecretRotationEvent{}) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if !matched { + t.Error("expected RegularExpression to match an identical pattern") + } +} + +// TestEvaluate_RegularExpression_InvalidPattern verifies that a malformed +// regular expression is surfaced as an error rather than silently treated +// as "no match" - a misconfigured MatchStrategy should be loud, not quietly +// inert. +func TestEvaluate_RegularExpression_InvalidPattern(t *testing.T) { + es := externalSecretWithExtractKey("my-secret") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "(", Operation: v1alpha1.ConditionOperationIn}, + }, + } + _, err := Evaluate(ms, es, events.SecretRotationEvent{}) + if err == nil { + t.Fatal("expected an error when the regular expression fails to compile") + } +} + +// TestEvaluate_UnsupportedOperation verifies that an unrecognized operation +// string is surfaced as an error rather than silently treated as "no match". +func TestEvaluate_UnsupportedOperation(t *testing.T) { + es := externalSecretWithExtractKey("my-secret") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "my-secret", Operation: v1alpha1.ConditionOperation("Equals")}, // typo: not "Equal" + }, + } + _, err := Evaluate(ms, es, events.SecretRotationEvent{}) + if err == nil { + t.Fatal("expected an error for an unrecognized condition operation") + } +} + +func TestEvaluate_ContainedByInverse(t *testing.T) { + es := externalSecretWithExtractKey("my-secret") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "totally-unrelated-arn-like-string", Operation: v1alpha1.ConditionOperationContainedBy}, + }, + } + matched, err := Evaluate(ms, es, events.SecretRotationEvent{}) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if matched { + t.Error("expected no match when the field value is not a substring of the condition value") + } +} + +func TestEvaluate_NotContainedBy(t *testing.T) { + es := externalSecretWithExtractKey("platform/ai-gateway/service-secrets") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "totally-unrelated-arn-like-string", Operation: v1alpha1.ConditionOperationNotContainedBy}, + }, + } + matched, err := Evaluate(ms, es, events.SecretRotationEvent{}) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if !matched { + t.Error("expected NotContainedBy to match when the field value is not embedded in the condition value") + } +} + +func TestEvaluate_NotContainedBy_NoMatchWhenContained(t *testing.T) { + es := externalSecretWithExtractKey("platform/ai-gateway/service-secrets") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "{{ .SecretIdentifier }}", Operation: v1alpha1.ConditionOperationNotContainedBy}, + }, + } + event := events.SecretRotationEvent{ + SecretIdentifier: "arn:aws:secretsmanager:us-east-1:051826739313:secret:platform/ai-gateway/service-secrets-78YXTj", + } + matched, err := Evaluate(ms, es, event) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if matched { + t.Error("expected NotContainedBy to not match when the field value is embedded in the condition value") + } +} + +func TestEvaluate_MultipleConditionsAreANDed(t *testing.T) { + es := externalSecretWithExtractKey("platform/ai-gateway/service-secrets") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "ai-gateway", Operation: v1alpha1.ConditionOperationContains}, + {Value: "jellyfish", Operation: v1alpha1.ConditionOperationContains}, + }, + } + matched, err := Evaluate(ms, es, events.SecretRotationEvent{}) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if matched { + t.Error("expected no match when not all conditions are satisfied") + } +} + +func TestEvaluate_MultipleValuesAtPathAreORed(t *testing.T) { + es := &esov1.ExternalSecret{ + ObjectMeta: metav1.ObjectMeta{Name: "es", Namespace: "default"}, + Spec: esov1.ExternalSecretSpec{ + DataFrom: []esov1.ExternalSecretDataFromRemoteRef{ + {Extract: &esov1.ExternalSecretDataRemoteRef{Key: "other-secret"}}, + {Extract: &esov1.ExternalSecretDataRemoteRef{Key: "my-secret"}}, + }, + }, + } + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "my-secret", Operation: v1alpha1.ConditionOperationEqual}, + }, + } + matched, err := Evaluate(ms, es, events.SecretRotationEvent{}) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if !matched { + t.Error("expected a match when any value found at path satisfies the conditions") + } +} + +func TestEvaluate_PathWithNoResultsDoesNotError(t *testing.T) { + es := externalSecretWithExtractKey("my-secret") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.target.template.metadata.labels.does-not-exist", + Conditions: []v1alpha1.Condition{ + {Value: "anything", Operation: v1alpha1.ConditionOperationEqual}, + }, + } + matched, err := Evaluate(ms, es, events.SecretRotationEvent{}) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if matched { + t.Error("expected no match when the path resolves to nothing") + } +} + +func TestEvaluate_RequiresPath(t *testing.T) { + es := externalSecretWithExtractKey("my-secret") + ms := &v1alpha1.MatchStrategy{ + Conditions: []v1alpha1.Condition{{Value: "x", Operation: v1alpha1.ConditionOperationEqual}}, + } + if _, err := Evaluate(ms, es, events.SecretRotationEvent{}); err == nil { + t.Error("expected an error when path is empty") + } +} + +func TestEvaluate_RequiresConditions(t *testing.T) { + es := externalSecretWithExtractKey("my-secret") + ms := &v1alpha1.MatchStrategy{Path: "spec.dataFrom[*].extract.key"} + if _, err := Evaluate(ms, es, events.SecretRotationEvent{}); err == nil { + t.Error("expected an error when conditions is empty") + } +} + +func TestEvaluate_RequiresNonNilStrategy(t *testing.T) { + es := externalSecretWithExtractKey("my-secret") + if _, err := Evaluate(nil, es, events.SecretRotationEvent{}); err == nil { + t.Error("expected an error when matchStrategy is nil") + } +} + +func TestEvaluate_InvalidTemplateErrors(t *testing.T) { + es := externalSecretWithExtractKey("my-secret") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "{{ .DoesNotExist }}", Operation: v1alpha1.ConditionOperationEqual}, + }, + } + if _, err := Evaluate(ms, es, events.SecretRotationEvent{}); err == nil { + t.Error("expected an error when the condition template references an unknown field") + } +} + +// TestEvaluate_MalformedTemplateSyntaxErrors covers a template that fails to +// even parse (as opposed to TestEvaluate_InvalidTemplateErrors, which fails +// at execution time referencing an unknown field). +func TestEvaluate_MalformedTemplateSyntaxErrors(t *testing.T) { + es := externalSecretWithExtractKey("my-secret") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "{{ .SecretIdentifier ", Operation: v1alpha1.ConditionOperationEqual}, // unterminated action + }, + } + if _, err := Evaluate(ms, es, events.SecretRotationEvent{}); err == nil { + t.Error("expected an error when the condition template fails to parse") + } +} + +// TestEvaluate_InvalidPathSyntaxErrors verifies that a malformed JSONPath +// expression is surfaced as an error, not silently treated as "no results". +func TestEvaluate_InvalidPathSyntaxErrors(t *testing.T) { + es := externalSecretWithExtractKey("my-secret") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*", // unterminated bracket + Conditions: []v1alpha1.Condition{ + {Value: "my-secret", Operation: v1alpha1.ConditionOperationEqual}, + }, + } + if _, err := Evaluate(ms, es, events.SecretRotationEvent{}); err == nil { + t.Error("expected an error for a malformed jsonpath expression") + } +} + +// TestEvaluate_AllTemplateFieldsAreAvailable exercises every documented +// template field (not just SecretIdentifier) to lock in the contract that +// all of events.SecretRotationEvent's exported fields are usable. +func TestEvaluate_AllTemplateFieldsAreAvailable(t *testing.T) { + event := events.SecretRotationEvent{ + SecretIdentifier: "secret-id", + RotationTimestamp: "2026-07-23T17:25:37Z", + TriggerSource: "aws-secretsmanager", + Namespace: "ai-gateway", + } + + tests := []struct { + name string + template string + key string + }{ + {"SecretIdentifier", "{{ .SecretIdentifier }}", "secret-id"}, + {"RotationTimestamp", "{{ .RotationTimestamp }}", "2026-07-23T17:25:37Z"}, + {"TriggerSource", "{{ .TriggerSource }}", "aws-secretsmanager"}, + {"Namespace", "{{ .Namespace }}", "ai-gateway"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + es := externalSecretWithExtractKey(tt.key) + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: tt.template, Operation: v1alpha1.ConditionOperationEqual}, + }, + } + matched, err := Evaluate(ms, es, event) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if !matched { + t.Errorf("expected %s to render to %q and match", tt.template, tt.key) + } + }) + } +} + +// TestEvaluate_MixedOperationsAreANDed verifies AND semantics across +// conditions using different operation types together, not just two +// conditions of the same operation. +func TestEvaluate_MixedOperationsAreANDed(t *testing.T) { + es := externalSecretWithExtractKey("platform/ai-gateway/service-secrets") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "ai-gateway", Operation: v1alpha1.ConditionOperationContains}, + {Value: "platform/ai-gateway/service-secrets", Operation: v1alpha1.ConditionOperationEqual}, + {Value: "jellyfish", Operation: v1alpha1.ConditionOperationNotContains}, + }, + } + matched, err := Evaluate(ms, es, events.SecretRotationEvent{}) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if !matched { + t.Error("expected a match when every condition, of different operation types, is individually satisfied") + } +} + +// TestEvaluate_ORAndANDCombine verifies that when path resolves to multiple +// values, and only some of those values satisfy the full set of ANDed +// conditions, the overall result is still a match (OR-across-values, +// AND-across-conditions must compose correctly, not just work in isolation). +func TestEvaluate_ORAndANDCombine(t *testing.T) { + es := &esov1.ExternalSecret{ + ObjectMeta: metav1.ObjectMeta{Name: "es", Namespace: "default"}, + Spec: esov1.ExternalSecretSpec{ + DataFrom: []esov1.ExternalSecretDataFromRemoteRef{ + // Satisfies Contains("ai-gateway") but not Contains("jellyfish"). + {Extract: &esov1.ExternalSecretDataRemoteRef{Key: "platform/ai-gateway/service-secrets"}}, + // Satisfies neither condition. + {Extract: &esov1.ExternalSecretDataRemoteRef{Key: "unrelated"}}, + // Satisfies both conditions - this is the one that should make it match overall. + {Extract: &esov1.ExternalSecretDataRemoteRef{Key: "platform/jellyfish-ai-gateway/service-secrets"}}, + }, + }, + } + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "ai-gateway", Operation: v1alpha1.ConditionOperationContains}, + {Value: "jellyfish", Operation: v1alpha1.ConditionOperationContains}, + }, + } + matched, err := Evaluate(ms, es, events.SecretRotationEvent{}) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if !matched { + t.Error("expected a match because at least one of the values at path satisfies every condition") + } +} + +// TestEvaluate_EmptyConditionValueContainsEverything documents (deliberately, +// not as a desired feature) that ContainedBy/Contains with an empty rendered +// condition value behaves like strings.Contains(s, "") always does: it +// matches unconditionally. This is exercised explicitly so the behavior is +// locked in and visible, since it's easy to hit by accident (e.g. +// templating an event field that happens to be empty). +func TestEvaluate_EmptyConditionValueContainsEverything(t *testing.T) { + es := externalSecretWithExtractKey("platform/ai-gateway/service-secrets") + ms := &v1alpha1.MatchStrategy{ + Path: "spec.dataFrom[*].extract.key", + Conditions: []v1alpha1.Condition{ + {Value: "{{ .Namespace }}", Operation: v1alpha1.ConditionOperationContains}, // Namespace left unset on the event + }, + } + matched, err := Evaluate(ms, es, events.SecretRotationEvent{}) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if !matched { + t.Error("expected Contains against an empty string to match, per strings.Contains semantics") + } +} + +// TestEvaluate_NonStringFieldValue verifies that a path resolving to a +// non-string JSON value (a number here) is stringified and compared +// correctly rather than causing an error or a silent non-match. +func TestEvaluate_NonStringFieldValue(t *testing.T) { + es := &esov1.ExternalSecret{ + ObjectMeta: metav1.ObjectMeta{Name: "es", Namespace: "default", Generation: 5}, + Spec: esov1.ExternalSecretSpec{}, + } + ms := &v1alpha1.MatchStrategy{ + Path: "metadata.generation", + Conditions: []v1alpha1.Condition{ + {Value: "5", Operation: v1alpha1.ConditionOperationEqual}, + }, + } + matched, err := Evaluate(ms, es, events.SecretRotationEvent{}) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if !matched { + t.Error("expected a numeric field value to stringify to a plain integer for comparison") + } +} + +// --- White-box tests for unexported helpers --- + +func TestToJSONPathTemplate(t *testing.T) { + tests := map[string]string{ + "spec.dataFrom[*].extract.key": "{.spec.dataFrom[*].extract.key}", + ".spec.dataFrom.key": "{.spec.dataFrom.key}", + "metadata.name": "{.metadata.name}", + } + for in, want := range tests { + if got := toJSONPathTemplate(in); got != want { + t.Errorf("toJSONPathTemplate(%q) = %q, want %q", in, got, want) + } + } +} + +func TestStringify(t *testing.T) { + strPtr := "hello" + var nilStrPtr *string + var nilAnyPtr any + + tests := []struct { + name string + val reflect.Value + want string + }{ + {"string", reflect.ValueOf("hello"), "hello"}, + {"empty string", reflect.ValueOf(""), ""}, + {"bool true", reflect.ValueOf(true), "true"}, + {"bool false", reflect.ValueOf(false), "false"}, + {"float64 whole number", reflect.ValueOf(float64(5)), "5"}, + {"float64 fractional", reflect.ValueOf(float64(1.5)), "1.5"}, + {"pointer to string", reflect.ValueOf(&strPtr), "hello"}, + // A nil *string, wrapped in an addressable interface so stringify sees + // a Ptr kind to dereference (this is the shape jsonpath.FindResults + // actually returns for an unset pointer field), must not panic. + {"nil pointer", reflect.ValueOf(&nilStrPtr).Elem(), ""}, + // reflect.ValueOf(nil) (a truly untyped nil interface) is the + // zero reflect.Value and must not panic either. + {"invalid/zero value", reflect.ValueOf(nilAnyPtr), ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := stringify(tt.val); got != tt.want { + t.Errorf("stringify(...) = %q, want %q", got, tt.want) + } + }) + } +} + +// TestEvaluate_PathRuntimeErrorErrors covers a path that parses successfully +// but fails at evaluation time - e.g. indexing into a field that isn't a +// list - as distinct from TestEvaluate_InvalidPathSyntaxErrors, which fails +// to parse at all. +func TestEvaluate_PathRuntimeErrorErrors(t *testing.T) { + es := externalSecretWithExtractKey("my-secret") + ms := &v1alpha1.MatchStrategy{ + Path: "metadata.name[0]", // metadata.name is a string, not a list + Conditions: []v1alpha1.Condition{ + {Value: "m", Operation: v1alpha1.ConditionOperationEqual}, + }, + } + if _, err := Evaluate(ms, es, events.SecretRotationEvent{}); err == nil { + t.Error("expected an error when the path indexes into a non-list field") + } +} + +// unmarshalableObject is a minimal client.Object whose JSON marshaling +// always fails (complex128 has no JSON representation), used to exercise +// valuesAtPath's json.Marshal error branch, which no real Kubernetes object +// can trigger. +type unmarshalableObject struct { + metav1.TypeMeta + metav1.ObjectMeta + Bad complex128 `json:"bad"` +} + +func (u *unmarshalableObject) DeepCopyObject() runtime.Object { + cp := *u + return &cp +} + +func TestEvaluate_ObjectMarshalErrorErrors(t *testing.T) { + obj := &unmarshalableObject{Bad: complex(1, 2)} + ms := &v1alpha1.MatchStrategy{ + Path: "bad", + Conditions: []v1alpha1.Condition{ + {Value: "anything", Operation: v1alpha1.ConditionOperationEqual}, + }, + } + if _, err := Evaluate(ms, obj, events.SecretRotationEvent{}); err == nil { + t.Error("expected an error when the destination object cannot be marshaled to JSON") + } +} + +func TestRenderConditionValue(t *testing.T) { + event := events.SecretRotationEvent{SecretIdentifier: "my-id"} + + got, err := renderConditionValue("plain-string-no-template", event) + if err != nil { + t.Fatalf("renderConditionValue: %v", err) + } + if got != "plain-string-no-template" { + t.Errorf("expected a literal value with no template actions to be returned unchanged, got %q", got) + } + + got, err = renderConditionValue("id={{ .SecretIdentifier }}", event) + if err != nil { + t.Fatalf("renderConditionValue: %v", err) + } + if got != "id=my-id" { + t.Errorf("expected template interpolation, got %q", got) + } +}