Browse Source

feat(azure): add contentType support for PushSecret (#6249)

* feat(azure): add contentType support for push secrets
Implements the contentType field for Azure KeyVault PushSecrets.
The contentType is appropriately extracted from the PushSecretMetadata
and correctly evaluated for changes against existing secrets using
both the legacy and new SDKs.
Fixes #6189

Signed-off-by: ppatel1604 <p.patel81@yahoo.com>

* test(azure): add unit tests for contentType update logic
Adds comprehensive test coverage for the contentType parameter
in Azure Key Vault PushSecrets, including addition, removal,
modification, and its interaction with expirationDate.

Signed-off-by: ppatel1604 <p.patel81@yahoo.com>

* docs(azure): document contentType attribute for PushSecret metadata
Introduces the contentType property definition and provides a
complete example snippet for the Azure KeyVault provider.

Signed-off-by: ppatel1604 <p.patel81@yahoo.com>

* fix(docs): the table style and use a future expiration example

Signed-off-by: ppatel1604 <p.patel81@yahoo.com>

* fix(azure): refactor as per coderabbitai comment

Signed-off-by: ppatel1604 <p.patel81@yahoo.com>

* fix(azure): refactor tests as per coderabbitai comment

Signed-off-by: ppatel1604 <p.patel81@yahoo.com>

* fix(azure): address the comments

Signed-off-by: ppatel1604 <p.patel81@yahoo.com>

* fix(azure): address the coderabbitai comments

Signed-off-by: ppatel1604 <p.patel81@yahoo.com>

* fix(docs): address the coderabbitai comments

Signed-off-by: ppatel1604 <p.patel81@yahoo.com>

---------

Signed-off-by: ppatel1604 <p.patel81@yahoo.com>
Co-authored-by: Gergely Bräutigam <gergely.brautigam@sap.com>
Parth Patel 1 month ago
parent
commit
8eb3e21eb4

+ 12 - 1
docs/provider/azure-key-vault.md

@@ -244,7 +244,15 @@ To fetch a P12 certificate (also known as PKCS12 or PFX) from Azure Key Vault an
 You can push secrets from Kubernetes into Azure Key Vault as secrets, keys or certificates by using a `PushSecret`. A `PushSecret` references a Kubernetes Secret as the source of the data. The operator can create, update or delete the corresponding secret in Azure Key Vault to match the desired state defined in the `PushSecret`.
 
 #### Pushing to a Secret
-Pushing to a Secret requires no previous setup. Provided you have a Kubernetes Secret available, you can create a `PushSecret` which references it to have it created on Azure Key Vault. You can optionally set metadata such as content type or tags. The operator will read the data from the Kubernetes Secret and push it to Azure Key Vault as a secret.
+Pushing to a Secret requires no previous setup. Provided you have a Kubernetes Secret available, you can create a `PushSecret` which references it to have it created on Azure Key Vault. The operator will read the data from the Kubernetes Secret and push it to Azure Key Vault as a secret.
+
+You can optionally attach metadata to the secret via the `spec.data[].metadata` field. The following fields are supported:
+
+| Field | Type | Description
+|---|---|---
+| `expirationDate` | string | Expiration date for the secret in RFC3339 format (e.g. `2099-12-31T23:59:59Z`).
+| `contentType` | string | Content type of the secret value (e.g. `application/json`, `text/plain`).
+| `tags` | map[string]string | Arbitrary key-value tags attached to the secret in Azure Key Vault.
 
 ```yaml
 {% include 'azkv-pushsecret-secret.yaml' %}
@@ -253,6 +261,9 @@ Pushing to a Secret requires no previous setup. Provided you have a Kubernetes S
 !!! note
     In order to create a PushSecret targeting Secrets, the [Key Vault Secrets Officer](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-secrets-officer) role, alternatively Access Policy permissions `Set` and `Delete` for Secrets must be granted to the identity configured on the SecretStore.
 
+!!! note
+    Omitting `contentType` (or setting it to an empty string) is interpreted as "don't change" rather than "clear": if the secret in Azure Key Vault already has a `ContentType` set, it will be preserved on update. There is currently no way to clear an existing `ContentType` via PushSecret — if you need to remove it, delete the secret from Azure Key Vault directly and let PushSecret recreate it.
+
 #### Pushing to a Key
 The first step is to generate a valid private key. Supported formats include `PRIVATE KEY`, `RSA PRIVATE KEY` AND `EC PRIVATE KEY` (EC/PKCS1/PKCS8 types). After uploading your key to a Kubernetes Secret, the next step is to create a PushSecret manifest with the following configuration:
 

+ 2 - 1
docs/snippets/azkv-pushsecret-secret.yaml

@@ -29,5 +29,6 @@ spec:
         kind: PushSecretMetadata
         spec:
           expirationDate: "2024-12-31T23:59:59Z" # Expiration date for the secret in Azure Key Vault
+          contentType: "application/json" # Content type of the secret value in Azure Key Vault
           tags: # Tags to be added to the secret in Azure Key Vault
-            Content-Type: application/json
+            environment: production

+ 6 - 1
providers/v1/azure/keyvault/fake/fake.go

@@ -33,6 +33,10 @@ type AzureMockClient struct {
 	deleteCertificate  func(ctx context.Context, vaultBaseURL string, certificateName string) (result keyvault.DeletedCertificateBundle, err error)
 	deleteKey          func(ctx context.Context, vaultBaseURL string, keyName string) (result keyvault.DeletedKeyBundle, err error)
 	deleteSecret       func(ctx context.Context, vaultBaseURL string, secretName string) (result keyvault.DeletedSecretBundle, err error)
+
+	// LastSetSecretParams captures the parameters passed to the most recent
+	// SetSecret call. Nil if SetSecret was never invoked.
+	LastSetSecretParams *keyvault.SecretSetParameters
 }
 
 func (mc *AzureMockClient) GetSecret(ctx context.Context, vaultBaseURL, secretName, secretVersion string) (result keyvault.SecretBundle, err error) {
@@ -121,7 +125,8 @@ func (mc *AzureMockClient) WithImportKey(output keyvault.KeyBundle, err error) {
 
 func (mc *AzureMockClient) WithSetSecret(output keyvault.SecretBundle, err error) {
 	if mc != nil {
-		mc.setSecret = func(_ context.Context, _, _ string, _ keyvault.SecretSetParameters) (keyvault.SecretBundle, error) {
+		mc.setSecret = func(_ context.Context, _, _ string, params keyvault.SecretSetParameters) (keyvault.SecretBundle, error) {
+			mc.LastSetSecretParams = &params
 			return output, err
 		}
 	}

+ 45 - 13
providers/v1/azure/keyvault/keyvault.go

@@ -154,9 +154,10 @@ type Azure struct {
 }
 
 // PushSecretMetadataSpec defines metadata for pushing secrets to Azure Key Vault,
-// including expiration date and tags.
+// including expiration date, content type, and tags.
 type PushSecretMetadataSpec struct {
 	ExpirationDate string            `json:"expirationDate,omitempty"`
+	ContentType    string            `json:"contentType,omitempty"`
 	Tags           map[string]string `json:"tags,omitempty"`
 }
 
@@ -549,7 +550,31 @@ func canCreate(tags map[string]*string, err error) (bool, error) {
 	return true, nil
 }
 
-func (a *Azure) setKeyVaultSecret(ctx context.Context, secretName string, value []byte, expires *date.UnixTime, tags map[string]string) error {
+func comp[T comparable](a, b *T) bool {
+	return (a == nil && b == nil) || (a != nil && b != nil && *a == *b)
+}
+
+func legacySecretUnchanged(secret keyvault.SecretBundle, value []byte, expires *date.UnixTime, contentType *string) bool {
+	if secret.Value == nil || string(value) != *secret.Value {
+		return false
+	}
+	var existingExpires *date.UnixTime
+	if secret.Attributes != nil {
+		existingExpires = secret.Attributes.Expires
+	}
+	if !comp(existingExpires, expires) {
+		return false
+	}
+	// contentType == nil means the caller did not request any specific
+	// contentType; treat it as "don't care" so we don't reconcile solely
+	// because the existing secret has a contentType set.
+	if contentType == nil {
+		return true
+	}
+	return comp(secret.ContentType, contentType)
+}
+
+func (a *Azure) setKeyVaultSecret(ctx context.Context, secretName string, value []byte, expires *date.UnixTime, contentType *string, tags map[string]string) error {
 	secret, err := a.baseClient.GetSecret(ctx, *a.provider.VaultURL, secretName, "")
 	metrics.ObserveAPICall(constants.ProviderAzureKV, constants.CallAzureKVGetSecret, err)
 	ok, err := canCreate(secret.Tags, err)
@@ -559,16 +584,16 @@ func (a *Azure) setKeyVaultSecret(ctx context.Context, secretName string, value
 	if !ok {
 		return nil
 	}
-	val := string(value)
-	if secret.Value != nil && val == *secret.Value {
-		if secret.Attributes != nil {
-			if (secret.Attributes.Expires == nil && expires == nil) ||
-				(secret.Attributes.Expires != nil && expires != nil && *secret.Attributes.Expires == *expires) {
-				return nil
-			}
-		}
+	if legacySecretUnchanged(secret, value, expires, contentType) {
+		return nil
+	}
+
+	effectiveContentType := contentType
+	if effectiveContentType == nil {
+		effectiveContentType = secret.ContentType
 	}
 
+	val := string(value)
 	secretParams := keyvault.SecretSetParameters{
 		Value: &val,
 		Tags: map[string]*string{
@@ -577,6 +602,7 @@ func (a *Azure) setKeyVaultSecret(ctx context.Context, secretName string, value
 		SecretAttributes: &keyvault.SecretAttributes{
 			Enabled: new(true),
 		},
+		ContentType: effectiveContentType,
 	}
 
 	for k, v := range tags {
@@ -588,7 +614,7 @@ func (a *Azure) setKeyVaultSecret(ctx context.Context, secretName string, value
 	}
 
 	_, err = a.baseClient.SetSecret(ctx, *a.provider.VaultURL, secretName, secretParams)
-	metrics.ObserveAPICall(constants.ProviderAzureKV, constants.CallAzureKVGetSecret, err)
+	metrics.ObserveAPICall(constants.ProviderAzureKV, constants.CallAzureKVSetSecret, err)
 	if err != nil {
 		return fmt.Errorf("could not set secret %v: %w", secretName, err)
 	}
@@ -737,6 +763,12 @@ func (a *Azure) PushSecret(ctx context.Context, secret *corev1.Secret, data esv1
 		expires = &unixTime
 	}
 
+	var contentType *string
+	if metadata != nil && metadata.Spec.ContentType != "" {
+		ct := metadata.Spec.ContentType
+		contentType = &ct
+	}
+
 	if metadata != nil && metadata.Spec.Tags != nil {
 		if _, exists := metadata.Spec.Tags[managedBy]; exists {
 			return fmt.Errorf("error parsing tags in metadata: Cannot specify a '%s' tag", managedBy)
@@ -748,9 +780,9 @@ func (a *Azure) PushSecret(ctx context.Context, secret *corev1.Secret, data esv1
 	switch objectType {
 	case defaultObjType:
 		if a.useNewSDK() {
-			return a.setKeyVaultSecretWithNewSDK(ctx, secretName, value, nil, tags)
+			return a.setKeyVaultSecretWithNewSDK(ctx, secretName, value, contentType, tags)
 		}
-		return a.setKeyVaultSecret(ctx, secretName, value, expires, tags)
+		return a.setKeyVaultSecret(ctx, secretName, value, expires, contentType, tags)
 	case objectTypeCert:
 		if a.useNewSDK() {
 			return a.setKeyVaultCertificateWithNewSDK(ctx, secretName, value, tags)

+ 44 - 23
providers/v1/azure/keyvault/keyvault_new_sdk.go

@@ -25,7 +25,6 @@ import (
 	"fmt"
 	"maps"
 	"regexp"
-	"time"
 
 	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
 	"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
@@ -44,31 +43,47 @@ import (
 	"github.com/external-secrets/external-secrets/runtime/metrics"
 )
 
+func isNotFoundErr(err error) bool {
+	var respErr *azcore.ResponseError
+	return errors.As(err, &respErr) && respErr.StatusCode == 404
+}
+
+func isManagedByESONewSDK(tags map[string]*string) bool {
+	if tags == nil {
+		return false
+	}
+	managedByTag, exists := tags[managedBy]
+	return exists && managedByTag != nil && *managedByTag == managerLabel
+}
+
+func newSDKSecretUnchanged(existingValue, existingContentType *string, value []byte, contentType *string) bool {
+	if existingValue == nil || string(value) != *existingValue {
+		return false
+	}
+	// contentType == nil means the caller did not request any specific
+	// contentType; treat it as "don't care" so we don't reconcile solely
+	// because the existing secret has a contentType set.
+	if contentType == nil {
+		return true
+	}
+	return comp(existingContentType, contentType)
+}
+
 // New SDK implementations for setter methods.
-func (a *Azure) setKeyVaultSecretWithNewSDK(ctx context.Context, secretName string, value []byte, _ *time.Time, tags map[string]string) error {
-	// Check if secret exists and if we can create/update it
+func (a *Azure) setKeyVaultSecretWithNewSDK(ctx context.Context, secretName string, value []byte, contentType *string, tags map[string]string) error {
 	existingSecret, err := a.secretsClient.GetSecret(ctx, secretName, "", nil)
 	metrics.ObserveAPICall(constants.ProviderAzureKV, constants.CallAzureKVGetSecret, err)
 
-	if err != nil {
-		var respErr *azcore.ResponseError
-		if !errors.As(err, &respErr) || respErr.StatusCode != 404 {
-			return fmt.Errorf("cannot get secret %v: %w", secretName, parseNewSDKError(err))
-		}
-	} else {
-		// Check if managed by external-secrets using new SDK tags
-		if existingSecret.Tags != nil {
-			if managedByTag, exists := existingSecret.Tags[managedBy]; !exists || managedByTag == nil || *managedByTag != managerLabel {
-				return fmt.Errorf("secret %v not managed by external-secrets", secretName)
-			}
+	if err != nil && !isNotFoundErr(err) {
+		return fmt.Errorf("cannot get secret %v: %w", secretName, parseNewSDKError(err))
+	}
+	if err == nil {
+		if !isManagedByESONewSDK(existingSecret.Tags) {
+			return fmt.Errorf("secret %v not managed by external-secrets", secretName)
 		}
-
-		// Check if secret content is the same
-		val := string(value)
-		if existingSecret.Value != nil && val == *existingSecret.Value {
-			// Note: We're not checking expiration here since the new SDK doesn't support setting it
-			// This means the new SDK implementation will always update the secret if the content is the same
-			// but different expiration is requested
+		// Note: the new SDK doesn't set expiration in SetSecretParameters, so
+		// changes to expiration alone won't trigger an update on this path.
+		if newSDKSecretUnchanged(existingSecret.Value, existingSecret.ContentType, value, contentType) {
 			return nil
 		}
 	}
@@ -81,11 +96,17 @@ func (a *Azure) setKeyVaultSecretWithNewSDK(ctx context.Context, secretName stri
 		secretTags[k] = &v
 	}
 
+	effectiveContentType := contentType
+	if effectiveContentType == nil {
+		effectiveContentType = existingSecret.ContentType
+	}
+
 	// Set the secret
 	val := string(value)
 	params := azsecrets.SetSecretParameters{
-		Value: &val,
-		Tags:  secretTags,
+		Value:       &val,
+		Tags:        secretTags,
+		ContentType: effectiveContentType,
 	}
 
 	// Note: The new SDK doesn't support setting expiration in SetSecretParameters

+ 203 - 0
providers/v1/azure/keyvault/keyvault_test.go

@@ -73,6 +73,9 @@ type secretManagerTestCase struct {
 	secret *corev1.Secret
 	// for testing changes in expiration date for akv secrets
 	newExpiry *date.UnixTime
+	// optional hook; called with the SetSecret params captured by the fake.
+	// params is nil if SetSecret was not invoked during the test.
+	verifySetSecret func(t *testing.T, key int, params *keyvault.SecretSetParameters)
 }
 
 func makeValidSecretManagerTestCase() *secretManagerTestCase {
@@ -130,6 +133,7 @@ const (
 	errNotManaged        = "not managed by external-secrets"
 	errNoPermission      = "No Permissions"
 	errAPI               = "unexpected api error"
+	contentTypeJSON      = "application/json"
 	something            = "something"
 	tagname              = "tagname"
 	tagname2             = "tagname2"
@@ -404,6 +408,28 @@ func TestAzureKeyVaultPushSecret(t *testing.T) {
 	secretKey := "fakeSecretKey"
 	tagKey := "fakeTagKey"
 	tagValue := "fakeTagValue"
+	expectSetSecretContentType := func(expected *string) func(t *testing.T, key int, params *keyvault.SecretSetParameters) {
+		return func(t *testing.T, key int, params *keyvault.SecretSetParameters) {
+			if params == nil {
+				t.Errorf("[%d] expected SetSecret to be called, but it was not", key)
+				return
+			}
+			got := params.ContentType
+			switch {
+			case expected == nil && got != nil:
+				t.Errorf("[%d] expected ContentType=nil, got %q", key, *got)
+			case expected != nil && got == nil:
+				t.Errorf("[%d] expected ContentType=%q, got nil", key, *expected)
+			case expected != nil && got != nil && *got != *expected:
+				t.Errorf("[%d] ContentType mismatch: got=%q expected=%q", key, *got, *expected)
+			}
+		}
+	}
+	expectSetSecretNotCalled := func(t *testing.T, key int, params *keyvault.SecretSetParameters) {
+		if params != nil {
+			t.Errorf("[%d] expected SetSecret to NOT be called, but it was (ContentType=%v)", key, params.ContentType)
+		}
+	}
 	mdataWithTag := &metadata.PushSecretMetadata[PushSecretMetadataSpec]{
 		APIVersion: metadata.APIVersion,
 		Kind:       metadata.Kind,
@@ -517,6 +543,174 @@ func TestAzureKeyVaultPushSecret(t *testing.T) {
 			Value: &goodSecret,
 		}
 	}
+	secretWithContentType := func(smtc *secretManagerTestCase) {
+		contentType := contentTypeJSON
+		mdata := &metadata.PushSecretMetadata[PushSecretMetadataSpec]{
+			APIVersion: metadata.APIVersion,
+			Kind:       metadata.Kind,
+			Spec: PushSecretMetadataSpec{
+				ContentType: contentType,
+			},
+		}
+		metadataRaw, _ := yaml.Marshal(mdata)
+		smtc.setValue = []byte("newSecret")
+		smtc.pushData = testingfake.PushSecretData{
+			SecretKey: secretKey,
+			RemoteKey: secretName,
+			Metadata: &apiextensionsv1.JSON{
+				Raw: metadataRaw,
+			},
+		}
+		smtc.secretOutput = keyvault.SecretBundle{
+			Tags: map[string]*string{
+				managedBy: new(externalSecrets),
+			},
+			Value:       &goodSecret,
+			ContentType: &contentType,
+		}
+		smtc.verifySetSecret = expectSetSecretContentType(&contentType)
+	}
+	secretNoChangeWithContentType := func(smtc *secretManagerTestCase) {
+		contentType := contentTypeJSON
+		mdata := &metadata.PushSecretMetadata[PushSecretMetadataSpec]{
+			APIVersion: metadata.APIVersion,
+			Kind:       metadata.Kind,
+			Spec: PushSecretMetadataSpec{
+				ContentType: contentType,
+			},
+		}
+		metadataRaw, _ := yaml.Marshal(mdata)
+		smtc.setValue = []byte(goodSecret)
+		smtc.pushData = testingfake.PushSecretData{
+			SecretKey: secretKey,
+			RemoteKey: secretName,
+			Metadata: &apiextensionsv1.JSON{
+				Raw: metadataRaw,
+			},
+		}
+		smtc.secretOutput = keyvault.SecretBundle{
+			Tags: map[string]*string{
+				managedBy: new(externalSecrets),
+			},
+			Value:       &goodSecret,
+			ContentType: &contentType,
+			Attributes:  &keyvault.SecretAttributes{},
+		}
+		smtc.setErr = errors.New("SetSecret should not be called when nothing changed")
+		smtc.verifySetSecret = expectSetSecretNotCalled
+	}
+	secretContentTypeChange := func(smtc *secretManagerTestCase) {
+		newContentType := contentTypeJSON
+		oldContentType := "text/plain"
+		mdata := &metadata.PushSecretMetadata[PushSecretMetadataSpec]{
+			APIVersion: metadata.APIVersion,
+			Kind:       metadata.Kind,
+			Spec: PushSecretMetadataSpec{
+				ContentType: newContentType,
+			},
+		}
+		metadataRaw, _ := yaml.Marshal(mdata)
+		smtc.setValue = []byte(goodSecret)
+		smtc.pushData = testingfake.PushSecretData{
+			SecretKey: secretKey,
+			RemoteKey: secretName,
+			Metadata: &apiextensionsv1.JSON{
+				Raw: metadataRaw,
+			},
+		}
+		smtc.secretOutput = keyvault.SecretBundle{
+			Tags: map[string]*string{
+				managedBy: new(externalSecrets),
+			},
+			Value:       &goodSecret,
+			ContentType: &oldContentType,
+			Attributes:  &keyvault.SecretAttributes{},
+		}
+		smtc.setErr = errors.New("content type changed, SetSecret called")
+		smtc.expectError = "content type changed, SetSecret called"
+		smtc.verifySetSecret = expectSetSecretContentType(&newContentType)
+	}
+	secretContentTypeAddedToExisting := func(smtc *secretManagerTestCase) {
+		newContentType := contentTypeJSON
+		mdata := &metadata.PushSecretMetadata[PushSecretMetadataSpec]{
+			APIVersion: metadata.APIVersion,
+			Kind:       metadata.Kind,
+			Spec: PushSecretMetadataSpec{
+				ContentType: newContentType,
+			},
+		}
+		metadataRaw, _ := yaml.Marshal(mdata)
+		smtc.setValue = []byte(goodSecret)
+		smtc.pushData = testingfake.PushSecretData{
+			SecretKey: secretKey,
+			RemoteKey: secretName,
+			Metadata: &apiextensionsv1.JSON{
+				Raw: metadataRaw,
+			},
+		}
+		smtc.secretOutput = keyvault.SecretBundle{
+			Tags: map[string]*string{
+				managedBy: new(externalSecrets),
+			},
+			Value:      &goodSecret,
+			Attributes: &keyvault.SecretAttributes{},
+		}
+		smtc.setErr = errors.New("contentType added, SetSecret called")
+		smtc.expectError = "contentType added, SetSecret called"
+		smtc.verifySetSecret = expectSetSecretContentType(&newContentType)
+	}
+	secretContentTypeOmittedFromRequest := func(smtc *secretManagerTestCase) {
+		existingContentType := contentTypeJSON
+		smtc.setValue = []byte(goodSecret)
+		smtc.pushData = testingfake.PushSecretData{
+			SecretKey: secretKey,
+			RemoteKey: secretName,
+		}
+		smtc.secretOutput = keyvault.SecretBundle{
+			Tags: map[string]*string{
+				managedBy: new(externalSecrets),
+			},
+			Value:       &goodSecret,
+			ContentType: &existingContentType,
+			Attributes:  &keyvault.SecretAttributes{},
+		}
+		smtc.setErr = errors.New("SetSecret should not be called when contentType is unset")
+		smtc.verifySetSecret = expectSetSecretNotCalled
+	}
+	secretContentTypeWithExpiration := func(smtc *secretManagerTestCase) {
+		contentType := contentTypeJSON
+		expiryTime, _ := time.Parse(time.RFC3339, "2099-12-31T23:59:59Z")
+		expiry := date.UnixTime(expiryTime)
+		mdata := &metadata.PushSecretMetadata[PushSecretMetadataSpec]{
+			APIVersion: metadata.APIVersion,
+			Kind:       metadata.Kind,
+			Spec: PushSecretMetadataSpec{
+				ContentType:    contentType,
+				ExpirationDate: "2099-12-31T23:59:59Z",
+			},
+		}
+		metadataRaw, _ := yaml.Marshal(mdata)
+		smtc.setValue = []byte(goodSecret)
+		smtc.pushData = testingfake.PushSecretData{
+			SecretKey: secretKey,
+			RemoteKey: secretName,
+			Metadata: &apiextensionsv1.JSON{
+				Raw: metadataRaw,
+			},
+		}
+		smtc.secretOutput = keyvault.SecretBundle{
+			Tags: map[string]*string{
+				managedBy: new(externalSecrets),
+			},
+			Value:       &goodSecret,
+			ContentType: &contentType,
+			Attributes: &keyvault.SecretAttributes{
+				Expires: &expiry,
+			},
+		}
+		smtc.setErr = errors.New("SetSecret should not be called when nothing changed")
+		smtc.verifySetSecret = expectSetSecretNotCalled
+	}
 	wholeSecretNoKey := func(smtc *secretManagerTestCase) {
 		wholeSecretMap := map[string][]byte{"key1": []byte(`value1`), "key2": []byte(`value2`)}
 		wholeSecretString := `{"key1": "value1", "key2": "value2" }`
@@ -960,6 +1154,12 @@ func TestAzureKeyVaultPushSecret(t *testing.T) {
 		makeValidSecretManagerTestCaseCustom(typeNotSupported),
 		makeValidSecretManagerTestCaseCustom(wholeSecretNoKey),
 		makeValidSecretManagerTestCaseCustom(secretWithTags),
+		makeValidSecretManagerTestCaseCustom(secretWithContentType),
+		makeValidSecretManagerTestCaseCustom(secretNoChangeWithContentType),
+		makeValidSecretManagerTestCaseCustom(secretContentTypeChange),
+		makeValidSecretManagerTestCaseCustom(secretContentTypeAddedToExisting),
+		makeValidSecretManagerTestCaseCustom(secretContentTypeOmittedFromRequest),
+		makeValidSecretManagerTestCaseCustom(secretContentTypeWithExpiration),
 		makeValidSecretManagerTestCaseCustom(certWithTags),
 		makeValidSecretManagerTestCaseCustom(keyWithTags),
 	}
@@ -984,6 +1184,9 @@ func TestAzureKeyVaultPushSecret(t *testing.T) {
 				t.Errorf(unexpectedError, k, err.Error(), v.expectError)
 			}
 		}
+		if v.verifySetSecret != nil {
+			v.verifySetSecret(t, k, v.mockClient.LastSetSecretParams)
+		}
 		if len(v.expectedData) > 0 {
 			sm.baseClient = v.mockClient
 			out, err := sm.GetSecretMap(context.Background(), *v.ref)