This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 5b7f3963e414 CAMEL-26255: camel-hashicorp-vault - cloud without a 
namespace uses the engine, createSecret takes JSON text and needs a body, and 
the secret version must be a number (#29100)
5b7f3963e414 is described below

commit 5b7f3963e414781c1f0fbe5250322fa2a0fdd1e7
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Nov 3 18:03:54 2026 +0100

    CAMEL-26255: camel-hashicorp-vault - cloud without a namespace uses the 
engine, createSecret takes JSON text and needs a body, and the secret version 
must be a number (#29100)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../camel/catalog/components/hashicorp-vault.json  |   2 +-
 .../catalog/docs/hashicorp-vault-component.adoc    |   9 +-
 components/camel-hashicorp-vault/pom.xml           |   6 +
 .../component/hashicorp/vault/hashicorp-vault.json |   2 +-
 .../src/main/docs/hashicorp-vault-component.adoc   |   9 +-
 .../hashicorp/vault/HashicorpVaultEndpoint.java    |  10 +-
 .../hashicorp/vault/HashicorpVaultProducer.java    |  96 +++++++---
 .../vault/HashicorpVaultPropertiesFunction.java    |  17 +-
 .../vault/HashicorpVaultProducerTest.java          | 203 +++++++++++++++++++++
 .../HashicorpVaultPropertiesFunctionTest.java      |  21 +++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  16 ++
 11 files changed, 346 insertions(+), 45 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/hashicorp-vault.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/hashicorp-vault.json
index e07af73a135e..0e3542c41d55 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/hashicorp-vault.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/hashicorp-vault.json
@@ -34,7 +34,7 @@
     "CamelHashicorpVaultSecretVersion": { "index": 2, "kind": "header", 
"displayName": "", "group": "producer", "label": "producer", "required": false, 
"javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": 
false, "secret": false, "description": "Set the desired secret version as 
header.", "constantName": 
"org.apache.camel.component.hashicorp.vault.HashicorpVaultConstants#SECRET_VERSION"
 }
   },
   "bodyTypes": {
-    "producerIn": { "carriers": [ "map", "pojo" ], "byOption": [ { "option": 
"operation", "value": "getSecret", "carriers": [ "any" ] }, { "option": 
"operation", "value": "deleteSecret", "carriers": [ "any" ] }, { "option": 
"operation", "value": "listSecrets", "carriers": [ "any" ] } ], "description": 
"The fields of the secret for createSecret, the default operation: a Map of the 
keys and values, or a POJO written as JSON. Text, bytes, a stream, a file or a 
List fail, so unmarshal JSON f [...]
+    "producerIn": { "carriers": [ "map", "pojo", "text", "bytes", "stream", 
"file" ], "byOption": [ { "option": "operation", "value": "getSecret", 
"carriers": [ "any" ] }, { "option": "operation", "value": "deleteSecret", 
"carriers": [ "any" ] }, { "option": "operation", "value": "listSecrets", 
"carriers": [ "any" ] } ], "description": "The fields of the secret for 
createSecret, the default operation: a Map of the keys and values, a POJO 
written as JSON, or text, bytes, a stream or a fil [...]
     "producerOut": { "carriers": [ "unchanged" ], "byOption": [ { "option": 
"operation", "value": "getSecret", "carriers": [ "map", "null" ] }, { "option": 
"operation", "value": "listSecrets", "carriers": [ "list" ], "javaType": 
"java.util.List<java.lang.String>" } ], "description": "getSecret gives a Map 
with the fields of the secret under data and its version under metadata, or no 
body when the secret does not exist or is deleted. listSecrets gives a List of 
the names of the secrets in [...]
   },
   "properties": {
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/hashicorp-vault-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/hashicorp-vault-component.adoc
index 98721af5462b..8e8352014961 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/hashicorp-vault-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/hashicorp-vault-component.adoc
@@ -50,7 +50,8 @@ The component can interact with HashiCorp Vault, which may be 
deployed either as
 When using a HashiCorp Vault Cloud instance, in addition to the standard 
parameters such as `host`, `port`, `scheme`, and `token`, you must configure 
the following additional parameters:
 
 * `cloud`:: A boolean flag that must be explicitly set to `true` to indicate 
that the HashiCorp Vault instance is hosted in the cloud.
-* `namespace`:: The namespace of your secrets engine.
+* `namespace`:: The namespace of your secrets engine. The operations work on 
`<namespace>/<secretsEngine>`; without a
+namespace they work on the secrets engine itself, as on-premise.
 
 
 == Examples
@@ -64,7 +65,9 @@ and `CamelHashicorpVaultSecretPath` headers override them per 
message.
 
 ==== Creating a secret
 
-The message body is the map of key/value pairs to store as the secret:
+The message body is the map of key/value pairs to store as the secret. A POJO 
is written as JSON, and text, bytes,
+a stream or a file must hold a JSON object of the fields. A message without a 
body fails, instead of writing a new,
+empty version of the secret:
 
 [tabs]
 ====
@@ -105,7 +108,7 @@ YAML::
 
 The message body becomes a Map with the fields of the secret under `data` and 
its version details under
 `metadata`, or no body when the secret does not exist; the 
`CamelHashicorpVaultSecretVersion` header reads a given
-version instead of the latest:
+version (a number) instead of the latest:
 
 [tabs]
 ====
diff --git a/components/camel-hashicorp-vault/pom.xml 
b/components/camel-hashicorp-vault/pom.xml
index 074ad574aef0..322de65749f6 100644
--- a/components/camel-hashicorp-vault/pom.xml
+++ b/components/camel-hashicorp-vault/pom.xml
@@ -60,6 +60,12 @@
             <artifactId>camel-test-junit6</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-junit-jupiter</artifactId>
+            <version>${mockito-version}</version>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>org.apache.camel</groupId>
             <artifactId>camel-test-infra-hashicorp-vault</artifactId>
diff --git 
a/components/camel-hashicorp-vault/src/generated/resources/META-INF/org/apache/camel/component/hashicorp/vault/hashicorp-vault.json
 
b/components/camel-hashicorp-vault/src/generated/resources/META-INF/org/apache/camel/component/hashicorp/vault/hashicorp-vault.json
index e07af73a135e..0e3542c41d55 100644
--- 
a/components/camel-hashicorp-vault/src/generated/resources/META-INF/org/apache/camel/component/hashicorp/vault/hashicorp-vault.json
+++ 
b/components/camel-hashicorp-vault/src/generated/resources/META-INF/org/apache/camel/component/hashicorp/vault/hashicorp-vault.json
@@ -34,7 +34,7 @@
     "CamelHashicorpVaultSecretVersion": { "index": 2, "kind": "header", 
"displayName": "", "group": "producer", "label": "producer", "required": false, 
"javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": 
false, "secret": false, "description": "Set the desired secret version as 
header.", "constantName": 
"org.apache.camel.component.hashicorp.vault.HashicorpVaultConstants#SECRET_VERSION"
 }
   },
   "bodyTypes": {
-    "producerIn": { "carriers": [ "map", "pojo" ], "byOption": [ { "option": 
"operation", "value": "getSecret", "carriers": [ "any" ] }, { "option": 
"operation", "value": "deleteSecret", "carriers": [ "any" ] }, { "option": 
"operation", "value": "listSecrets", "carriers": [ "any" ] } ], "description": 
"The fields of the secret for createSecret, the default operation: a Map of the 
keys and values, or a POJO written as JSON. Text, bytes, a stream, a file or a 
List fail, so unmarshal JSON f [...]
+    "producerIn": { "carriers": [ "map", "pojo", "text", "bytes", "stream", 
"file" ], "byOption": [ { "option": "operation", "value": "getSecret", 
"carriers": [ "any" ] }, { "option": "operation", "value": "deleteSecret", 
"carriers": [ "any" ] }, { "option": "operation", "value": "listSecrets", 
"carriers": [ "any" ] } ], "description": "The fields of the secret for 
createSecret, the default operation: a Map of the keys and values, a POJO 
written as JSON, or text, bytes, a stream or a fil [...]
     "producerOut": { "carriers": [ "unchanged" ], "byOption": [ { "option": 
"operation", "value": "getSecret", "carriers": [ "map", "null" ] }, { "option": 
"operation", "value": "listSecrets", "carriers": [ "list" ], "javaType": 
"java.util.List<java.lang.String>" } ], "description": "getSecret gives a Map 
with the fields of the secret under data and its version under metadata, or no 
body when the secret does not exist or is deleted. listSecrets gives a List of 
the names of the secrets in [...]
   },
   "properties": {
diff --git 
a/components/camel-hashicorp-vault/src/main/docs/hashicorp-vault-component.adoc 
b/components/camel-hashicorp-vault/src/main/docs/hashicorp-vault-component.adoc
index 98721af5462b..8e8352014961 100644
--- 
a/components/camel-hashicorp-vault/src/main/docs/hashicorp-vault-component.adoc
+++ 
b/components/camel-hashicorp-vault/src/main/docs/hashicorp-vault-component.adoc
@@ -50,7 +50,8 @@ The component can interact with HashiCorp Vault, which may be 
deployed either as
 When using a HashiCorp Vault Cloud instance, in addition to the standard 
parameters such as `host`, `port`, `scheme`, and `token`, you must configure 
the following additional parameters:
 
 * `cloud`:: A boolean flag that must be explicitly set to `true` to indicate 
that the HashiCorp Vault instance is hosted in the cloud.
-* `namespace`:: The namespace of your secrets engine.
+* `namespace`:: The namespace of your secrets engine. The operations work on 
`<namespace>/<secretsEngine>`; without a
+namespace they work on the secrets engine itself, as on-premise.
 
 
 == Examples
@@ -64,7 +65,9 @@ and `CamelHashicorpVaultSecretPath` headers override them per 
message.
 
 ==== Creating a secret
 
-The message body is the map of key/value pairs to store as the secret:
+The message body is the map of key/value pairs to store as the secret. A POJO 
is written as JSON, and text, bytes,
+a stream or a file must hold a JSON object of the fields. A message without a 
body fails, instead of writing a new,
+empty version of the secret:
 
 [tabs]
 ====
@@ -105,7 +108,7 @@ YAML::
 
 The message body becomes a Map with the fields of the secret under `data` and 
its version details under
 `metadata`, or no body when the secret does not exist; the 
`CamelHashicorpVaultSecretVersion` header reads a given
-version instead of the latest:
+version (a number) instead of the latest:
 
 [tabs]
 ====
diff --git 
a/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultEndpoint.java
 
b/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultEndpoint.java
index bcdb20601729..d26acd6b5af8 100644
--- 
a/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultEndpoint.java
+++ 
b/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultEndpoint.java
@@ -43,16 +43,16 @@ import org.springframework.vault.core.VaultTemplate;
 @Metadata(annotations = {
         "vault=hashicorp-vault",
 })
-@BodyTypes(producerIn = @BodyType(value = { Carrier.MAP, Carrier.POJO },
+@BodyTypes(producerIn = @BodyType(value = {
+        Carrier.MAP, Carrier.POJO, Carrier.TEXT, Carrier.BYTES, 
Carrier.STREAM, Carrier.FILE },
                                   byOption = {
                                           @BodyType.When(option = "operation", 
value = "getSecret", carriers = Carrier.ANY),
                                           @BodyType.When(option = "operation", 
value = "deleteSecret", carriers = Carrier.ANY),
                                           @BodyType.When(option = "operation", 
value = "listSecrets", carriers = Carrier.ANY) },
                                   description = "The fields of the secret for 
createSecret, the default operation: a Map of the"
-                                                + " keys and values, or a POJO 
written as JSON. Text, bytes, a stream, a file or a"
-                                                + " List fail, so unmarshal 
JSON first, and no body writes an empty version. The"
-                                                + " other operations take the 
secret from the secretPath option or the"
-                                                + " 
CamelHashicorpVaultSecretPath header"),
+                                                + " keys and values, a POJO 
written as JSON, or text, bytes, a stream or a file"
+                                                + " with a JSON object. A List 
or no body fails. The other operations take the"
+                                                + " secret from the secretPath 
option or the CamelHashicorpVaultSecretPath header"),
            producerOut = @BodyType(value = Carrier.UNCHANGED,
                                    byOption = {
                                            @BodyType.When(option = 
"operation", value = "getSecret",
diff --git 
a/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultProducer.java
 
b/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultProducer.java
index 89b1cf1883e5..be33017979d4 100644
--- 
a/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultProducer.java
+++ 
b/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultProducer.java
@@ -16,12 +16,21 @@
  */
 package org.apache.camel.component.hashicorp.vault;
 
-import java.util.ArrayList;
+import java.io.File;
+import java.io.InputStream;
+import java.io.Reader;
+import java.nio.file.Path;
 import java.util.List;
+import java.util.Map;
 
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.camel.Endpoint;
 import org.apache.camel.Exchange;
+import org.apache.camel.InvalidPayloadException;
 import org.apache.camel.Message;
+import org.apache.camel.StreamCache;
+import org.apache.camel.WrappedFile;
 import org.apache.camel.support.DefaultProducer;
 import org.apache.camel.util.ObjectHelper;
 import org.springframework.vault.core.VaultKeyValueOperations;
@@ -29,6 +38,9 @@ import 
org.springframework.vault.core.VaultKeyValueOperationsSupport;
 import org.springframework.vault.support.VaultResponse;
 
 public class HashicorpVaultProducer extends DefaultProducer {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
     public HashicorpVaultProducer(final Endpoint endpoint) {
         super(endpoint);
     }
@@ -54,12 +66,13 @@ public class HashicorpVaultProducer extends DefaultProducer 
{
         }
     }
 
-    private void createSecret(Exchange exchange) {
+    private void createSecret(Exchange exchange) throws 
InvalidPayloadException {
         String secretPath = getSecretPath(exchange);
+        Object secret = getSecretBody(exchange);
         VaultKeyValueOperations keyValue
-                = 
getEndpoint().getVaultTemplate().opsForKeyValue(getEndpoint().getConfiguration().getSecretsEngine(),
+                = 
getEndpoint().getVaultTemplate().opsForKeyValue(getEnginePath(),
                         
VaultKeyValueOperationsSupport.KeyValueBackend.versioned());
-        keyValue.put(secretPath, exchange.getMessage().getBody());
+        keyValue.put(secretPath, secret);
     }
 
     private void getSecret(Exchange exchange) {
@@ -68,17 +81,9 @@ public class HashicorpVaultProducer extends DefaultProducer {
         if 
(ObjectHelper.isNotEmpty(exchange.getMessage().getHeader(HashicorpVaultConstants.SECRET_VERSION)))
 {
             secretVersion = 
exchange.getMessage().getHeader(HashicorpVaultConstants.SECRET_VERSION, 
String.class);
         }
-        String completePath = "";
-        if (!getEndpoint().getConfiguration().isCloud()) {
-            completePath = getEndpoint().getConfiguration().getSecretsEngine() 
+ "/" + "data" + "/" + secretPath;
-        } else {
-            if 
(ObjectHelper.isNotEmpty(getEndpoint().getConfiguration().getNamespace())) {
-                completePath = getEndpoint().getConfiguration().getNamespace() 
+ "/"
-                               + 
getEndpoint().getConfiguration().getSecretsEngine() + "/" + "data" + "/" + 
secretPath;
-            }
-        }
+        String completePath = getEnginePath() + "/" + "data" + "/" + 
secretPath;
         if (ObjectHelper.isNotEmpty(secretVersion)) {
-            completePath = completePath + "?version=" + secretVersion;
+            completePath = completePath + "?version=" + 
parseSecretVersion(secretVersion);
         }
         VaultResponse rawSecret = 
getEndpoint().getVaultTemplate().read(completePath);
         if (rawSecret != null) {
@@ -91,24 +96,65 @@ public class HashicorpVaultProducer extends DefaultProducer 
{
     private void deleteSecret(Exchange exchange) {
         String secretPath = getSecretPath(exchange);
         VaultKeyValueOperations keyValue
-                = 
getEndpoint().getVaultTemplate().opsForKeyValue(getEndpoint().getConfiguration().getSecretsEngine(),
+                = 
getEndpoint().getVaultTemplate().opsForKeyValue(getEnginePath(),
                         
VaultKeyValueOperationsSupport.KeyValueBackend.versioned());
         keyValue.delete(secretPath);
     }
 
     private void listSecrets(Exchange exchange) {
-        List<String> secretsList = new ArrayList<>();
-        if (!getEndpoint().getConfiguration().isCloud()) {
-            secretsList = getEndpoint().getVaultTemplate()
-                    .list(getEndpoint().getConfiguration().getSecretsEngine() 
+ "/" + "metadata" + "/");
-        } else {
-            if 
(ObjectHelper.isNotEmpty(getEndpoint().getConfiguration().getNamespace())) {
-                secretsList = getEndpoint().getVaultTemplate()
-                        .list(getEndpoint().getConfiguration().getNamespace() 
+ "/"
-                              + 
getEndpoint().getConfiguration().getSecretsEngine() + "/" + "metadata" + "/");
+        List<String> secretsList = 
getEndpoint().getVaultTemplate().list(getEnginePath() + "/" + "metadata" + "/");
+        exchange.getMessage().setBody(secretsList);
+    }
+
+    /**
+     * The path of the secrets engine: in the namespace when running on 
HashiCorp Cloud with a namespace, else the
+     * engine itself (as the secret refresh task does).
+     */
+    private String getEnginePath() {
+        String engine = getConfiguration().getSecretsEngine();
+        if (getConfiguration().isCloud() && 
ObjectHelper.isNotEmpty(getConfiguration().getNamespace())) {
+            return getConfiguration().getNamespace() + "/" + engine;
+        }
+        return engine;
+    }
+
+    /**
+     * The fields of the secret to write: a Map or a POJO as is, and text, 
bytes, a stream or a file as a JSON object.
+     */
+    private static Object getSecretBody(Exchange exchange) throws 
InvalidPayloadException {
+        // no body would write a new, empty version of the secret
+        Object body = exchange.getMessage().getMandatoryBody();
+        if (body instanceof CharSequence || body instanceof byte[] || body 
instanceof InputStream
+                || body instanceof Reader || body instanceof StreamCache || 
body instanceof WrappedFile
+                || body instanceof File || body instanceof Path) {
+            String json = exchange.getMessage().getMandatoryBody(String.class);
+            Map<?, ?> fields;
+            try {
+                fields = MAPPER.readValue(json, Map.class);
+            } catch (JsonProcessingException e) {
+                // the cause is left out on purpose, as its message can quote 
the text of the secret
+                fields = null;
             }
+            if (fields == null) {
+                throw new IllegalArgumentException(
+                        "The body of createSecret must be a Map, a POJO or a 
JSON object with the fields of the secret");
+            }
+            return fields;
         }
-        exchange.getMessage().setBody(secretsList);
+        return body;
+    }
+
+    private static int parseSecretVersion(String secretVersion) {
+        try {
+            int version = Integer.parseInt(secretVersion.trim());
+            if (version >= 0) {
+                return version;
+            }
+        } catch (NumberFormatException e) {
+            // not a number
+        }
+        throw new IllegalArgumentException(
+                "The " + HashicorpVaultConstants.SECRET_VERSION + " header 
must be a version number, was: " + secretVersion);
     }
 
     @Override
diff --git 
a/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultPropertiesFunction.java
 
b/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultPropertiesFunction.java
index 04082f515402..6e583b36aae9 100644
--- 
a/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultPropertiesFunction.java
+++ 
b/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultPropertiesFunction.java
@@ -106,6 +106,12 @@ public class HashicorpVaultPropertiesFunction extends 
ServiceSupport implements
         this.client = client;
     }
 
+    HashicorpVaultPropertiesFunction(VaultTemplate client, boolean cloud, 
String namespace) {
+        this(client);
+        this.cloud = cloud;
+        this.namespace = namespace;
+    }
+
     @Override
     protected void doStart() throws Exception {
         super.doStart();
@@ -238,13 +244,10 @@ public class HashicorpVaultPropertiesFunction extends 
ServiceSupport implements
 
         String returnValue = null;
         try {
-            String completePath = "";
-            if (!cloud) {
-                completePath = engine + "/" + "data" + "/" + key;
-            } else {
-                if (ObjectHelper.isNotEmpty(namespace)) {
-                    completePath = namespace + "/" + engine + "/" + "data" + 
"/" + key;
-                }
+            String completePath = engine + "/" + "data" + "/" + key;
+            if (cloud && ObjectHelper.isNotEmpty(namespace)) {
+                // without a namespace the engine itself, as the secret 
refresh task does
+                completePath = namespace + "/" + completePath;
             }
             if (ObjectHelper.isNotEmpty(version)) {
                 completePath = completePath + "?version=" + version;
diff --git 
a/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultProducerTest.java
 
b/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultProducerTest.java
new file mode 100644
index 000000000000..01f57be92eeb
--- /dev/null
+++ 
b/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultProducerTest.java
@@ -0,0 +1,203 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *      http://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 org.apache.camel.component.hashicorp.vault;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.InvalidPayloadException;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.vault.core.VaultKeyValueOperations;
+import 
org.springframework.vault.core.VaultKeyValueOperationsSupport.KeyValueBackend;
+import org.springframework.vault.core.VaultTemplate;
+import org.springframework.vault.support.VaultResponse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * The producer operations against a mocked {@link VaultTemplate}: the paths 
it reads, lists and writes, and the body it
+ * writes.
+ */
+class HashicorpVaultProducerTest extends CamelTestSupport {
+
+    private final VaultTemplate vault = mock(VaultTemplate.class);
+    private final VaultKeyValueOperations keyValue = 
mock(VaultKeyValueOperations.class);
+
+    @BeforeEach
+    void bindVault() {
+        context.getRegistry().bind("vault", vault);
+        when(vault.opsForKeyValue(anyString(), 
any(KeyValueBackend.class))).thenReturn(keyValue);
+        VaultResponse response = new VaultResponse();
+        response.setData(Map.of("data", Map.of("id", "123")));
+        when(vault.read(anyString())).thenReturn(response);
+        when(vault.list(anyString())).thenReturn(List.of("hello"));
+    }
+
+    @Test
+    void getSecretOnCloudWithoutNamespace() {
+        Exchange exchange = send("getSecret&cloud=true", null);
+
+        assertNull(exchange.getException());
+        verify(vault).read("secret/data/hello");
+        assertEquals(Map.of("data", Map.of("id", "123")), 
exchange.getMessage().getBody());
+    }
+
+    @Test
+    void getSecretOnCloudWithNamespace() {
+        Exchange exchange = send("getSecret&cloud=true&namespace=admin", null);
+
+        assertNull(exchange.getException());
+        verify(vault).read("admin/secret/data/hello");
+    }
+
+    @Test
+    void getSecretOnPremiseIgnoresNamespace() {
+        Exchange exchange = send("getSecret&namespace=admin", null);
+
+        assertNull(exchange.getException());
+        verify(vault).read("secret/data/hello");
+    }
+
+    @Test
+    void listSecretsOnCloudWithoutNamespace() {
+        Exchange exchange = send("listSecrets&cloud=true", null);
+
+        assertNull(exchange.getException());
+        verify(vault).list("secret/metadata/");
+        assertEquals(List.of("hello"), exchange.getMessage().getBody());
+    }
+
+    @Test
+    void listSecretsOnCloudWithNamespace() {
+        Exchange exchange = send("listSecrets&cloud=true&namespace=admin", 
null);
+
+        assertNull(exchange.getException());
+        verify(vault).list("admin/secret/metadata/");
+    }
+
+    @Test
+    void createAndDeleteSecretOnCloudWithNamespace() {
+        assertNull(send("createSecret&cloud=true&namespace=admin", 
Map.of("id", "123")).getException());
+        assertNull(send("deleteSecret&cloud=true&namespace=admin", 
null).getException());
+
+        // the same engine path as getSecret and listSecrets read
+        verify(vault, times(2)).opsForKeyValue("admin/secret", 
KeyValueBackend.versioned());
+        verify(keyValue).put("hello", Map.of("id", "123"));
+        verify(keyValue).delete("hello");
+    }
+
+    @Test
+    void getSecretWithVersion() {
+        Exchange exchange = 
template.request("hashicorp-vault:secret?vaultTemplate=#vault&secretPath=hello"
+                                             + "&operation=getSecret",
+                e -> 
e.getMessage().setHeader(HashicorpVaultConstants.SECRET_VERSION, "2"));
+
+        assertNull(exchange.getException());
+        verify(vault).read("secret/data/hello?version=2");
+    }
+
+    @Test
+    void getSecretWithInvalidVersion() {
+        Exchange exchange = 
template.request("hashicorp-vault:secret?vaultTemplate=#vault&secretPath=hello"
+                                             + "&operation=getSecret",
+                e -> 
e.getMessage().setHeader(HashicorpVaultConstants.SECRET_VERSION, 
"2&list=true"));
+
+        IllegalArgumentException cause = 
assertInstanceOf(IllegalArgumentException.class, exchange.getException());
+        
assertTrue(cause.getMessage().contains(HashicorpVaultConstants.SECRET_VERSION), 
cause.getMessage());
+        verify(vault, never()).read(anyString());
+    }
+
+    @Test
+    void createSecretFromMap() {
+        assertNull(send("createSecret", Map.of("id", "123")).getException());
+
+        verify(keyValue).put("hello", Map.of("id", "123"));
+    }
+
+    @Test
+    void createSecretFromJsonText() {
+        assertNull(send("createSecret", "{\"id\":\"123\"}").getException());
+
+        verify(keyValue).put("hello", Map.of("id", "123"));
+    }
+
+    @Test
+    void createSecretFromJsonBytes() {
+        assertNull(send("createSecret", 
"{\"id\":\"123\"}".getBytes(StandardCharsets.UTF_8)).getException());
+
+        verify(keyValue).put("hello", Map.of("id", "123"));
+    }
+
+    @Test
+    void createSecretFromJsonStream() {
+        Exchange exchange = send("createSecret",
+                new 
ByteArrayInputStream("{\"id\":\"123\"}".getBytes(StandardCharsets.UTF_8)));
+
+        assertNull(exchange.getException());
+        verify(keyValue).put("hello", Map.of("id", "123"));
+    }
+
+    @Test
+    void createSecretFromTextThatIsNotAJsonObject() {
+        Exchange exchange = send("createSecret", "my-password");
+
+        IllegalArgumentException cause = 
assertInstanceOf(IllegalArgumentException.class, exchange.getException());
+        // the text may be the secret itself, so it is not in the message
+        assertFalse(cause.getMessage().contains("my-password"), 
cause.getMessage());
+        assertNull(cause.getCause());
+        verify(keyValue, never()).put(anyString(), any());
+    }
+
+    @Test
+    void createSecretFromJsonArray() {
+        Exchange exchange = send("createSecret", "[\"123\"]");
+
+        assertInstanceOf(IllegalArgumentException.class, 
exchange.getException());
+        verify(keyValue, never()).put(anyString(), any());
+    }
+
+    @Test
+    void createSecretWithoutBody() {
+        Exchange exchange = send("createSecret", null);
+
+        // no new, empty version of the secret
+        assertInstanceOf(InvalidPayloadException.class, 
exchange.getException());
+        verify(keyValue, never()).put(anyString(), any());
+    }
+
+    private Exchange send(String operationAndOptions, Object body) {
+        return 
template.request("hashicorp-vault:secret?vaultTemplate=#vault&secretPath=hello&operation="
+                                + operationAndOptions,
+                e -> e.getMessage().setBody(body));
+    }
+}
diff --git 
a/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultPropertiesFunctionTest.java
 
b/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultPropertiesFunctionTest.java
index 7e1efd682734..1a8e4f282c58 100644
--- 
a/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultPropertiesFunctionTest.java
+++ 
b/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultPropertiesFunctionTest.java
@@ -107,6 +107,27 @@ class HashicorpVaultPropertiesFunctionTest {
         assertTrue(mismatches.isEmpty(), "lookups read from the wrong engine: 
" + mismatches);
     }
 
+    @Test
+    void lookupOnCloudWithoutNamespaceReadsTheEngine() {
+        FakeVaultTemplate vault = new FakeVaultTemplate(
+                Map.of("secret/data/api", Map.of("password", "pw")));
+        HashicorpVaultPropertiesFunction function = new 
HashicorpVaultPropertiesFunction(vault, true, null);
+
+        assertEquals("pw", function.apply("secret:api#password"));
+        assertEquals("pw", function.apply("api#password"));
+        assertEquals(List.of("secret/data/api", "secret/data/api"), 
vault.reads);
+    }
+
+    @Test
+    void lookupOnCloudWithNamespaceReadsInTheNamespace() {
+        FakeVaultTemplate vault = new FakeVaultTemplate(
+                Map.of("admin/secret/data/api?version=2", Map.of("password", 
"pw")));
+        HashicorpVaultPropertiesFunction function = new 
HashicorpVaultPropertiesFunction(vault, true, "admin");
+
+        assertEquals("pw", function.apply("secret:api#password@2"));
+        assertEquals(List.of("admin/secret/data/api?version=2"), vault.reads);
+    }
+
     @Test
     void missingSecretWithFieldReturnsNull() {
         HashicorpVaultPropertiesFunction function = new 
HashicorpVaultPropertiesFunction(new FakeVaultTemplate(Map.of()));
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index cd8e0d953707..cef13898d65b 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -944,6 +944,22 @@ of the called method, and when that is not possible it 
sends nothing and ends th
 A route must set the response message of the method as the body. A reply 
without a body still sends an empty response
 message.
 
+=== camel-hashicorp-vault - cloud without a namespace, the createSecret body, 
and the secret version
+
+With `cloud=true` and no `namespace`, the `hashicorp-vault` producer now works 
on the secrets engine itself, as the
+secret refresh task already did. Before, `getSecret` failed with `Path must 
not be empty` and `listSecrets` returned an
+empty list. A `hashicorp:` property with `camel.vault.hashicorp.cloud=true` 
and no namespace now also reads the secret
+from the engine itself. With `cloud=true` and a `namespace`, `createSecret` 
and `deleteSecret` now work on the secret
+in the namespace (`<namespace>/<secretsEngine>`), as `getSecret` and 
`listSecrets` do. Before, they worked on the
+engine outside the namespace.
+
+`createSecret` now writes a body of text, bytes, a stream or a file that holds 
a JSON object as the fields of the
+secret; before, such a body failed. A body that is not a JSON object, and a 
message without a body, now fail the
+exchange. Before, a message without a body wrote a new, empty version of the 
secret.
+
+The `CamelHashicorpVaultSecretVersion` header of `getSecret` must be a version 
number; any other value now fails the
+exchange with an `IllegalArgumentException`.
+
 === camel-hivemq - a body that does not convert to a byte array fails the 
exchange
 
 The `hivemq` producer published a message body that has no conversion to 
`byte[]`, such as a `Map`, a POJO or a

Reply via email to