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 9712c5e70300 CAMEL-23688: camel-jbang - Tests for explain and harden
(#27971)
9712c5e70300 is described below
commit 9712c5e703008111c6b17d104b7c7f89eace0dc8
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Oct 16 22:29:04 2026 +0200
CAMEL-23688: camel-jbang - Tests for explain and harden (#27971)
Tests for camel explain (9% covered) and camel harden (14%) against a local
server that plays Ollama and an OpenAI-compatible service (FakeLlmServer),
which keeps the question it answered, so a test can check the prompt.
They found these bugs, fixed here:
* camel harden --api-type=openai printed only the header: with --stream
(the default) it took the answer as printed, but the OpenAI-compatible
service answers in one go.
* The LLM configuration, the progress and the prompts of --show-prompt
were printed on stdout with the answer; they go to stderr, so stdout
has only the answer.
* An unknown --format was ignored; now a usage error.
* An API key of up to four characters was shown whole in the
configuration; a key of eight characters or fewer is not shown.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 9 ++
.../camel/dsl/jbang/core/commands/Explain.java | 34 +++--
.../camel/dsl/jbang/core/commands/Harden.java | 47 ++++---
.../camel/dsl/jbang/core/commands/ExplainTest.java | 153 +++++++++++++++++++++
.../dsl/jbang/core/commands/FakeLlmServer.java | 105 ++++++++++++++
.../camel/dsl/jbang/core/commands/HardenTest.java | 148 ++++++++++++++++++++
6 files changed, 461 insertions(+), 35 deletions(-)
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 3668b9465e35..b332441dfd88 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
@@ -462,6 +462,15 @@ or follows, like Enter does.
printed. Files together with `--source-dir`, and `--background` with a
runtime that does not support it, exit with
`2`.
+=== camel-jbang - camel explain and camel harden
+
+* The LLM configuration, the progress and the prompts of `--show-prompt` are
printed on stderr, so stdout has only the
+ answer of the LLM (with a header per file), such as for `camel explain
route.yaml > explanation.md`.
+* An unknown `--format` exits with `2` (it was ignored).
+* `camel harden --api-type=openai` prints the suggestions (it printed only the
header): the OpenAI-compatible service
+ answers in one go, also with `--stream`.
+* An API key of eight characters or fewer is not shown in the configuration
(the last four characters of a key were).
+
=== camel-jbang - the AI tools report failures as errors
* `camel ask` runs a `camel` command for the model with a timeout of 60
seconds, and gives it the exit code, stdout and
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Explain.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Explain.java
index 9fc0e51ea173..10d8cdbdf369 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Explain.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Explain.java
@@ -27,6 +27,7 @@ import java.util.Optional;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
import org.apache.camel.dsl.jbang.core.commands.ai.RouteComponents;
+import org.apache.camel.dsl.jbang.core.common.CliException;
import org.apache.camel.dsl.jbang.core.common.CliExitCode;
import org.apache.camel.tooling.model.ComponentModel;
import org.apache.camel.tooling.model.EipModel;
@@ -150,6 +151,9 @@ public class Explain extends CamelCommand {
@Override
public Integer doCall() throws Exception {
+ if (!"text".equals(format) && !"markdown".equals(format)) {
+ throw CliException.usage("--format must be text or markdown, was:
" + format);
+ }
// check the input before reaching out to an LLM
for (String file : files) {
if (!Files.exists(Path.of(file))) {
@@ -185,20 +189,21 @@ public class Explain extends CamelCommand {
}
private void printConfiguration(LlmClient client) {
- printer().println("LLM Configuration:");
- printer().println(" URL: " + (client.url != null ? client.url :
"(Vertex AI)"));
- printer().println(" API Type: " + client.apiType);
- printer().println(" Model: " + client.model);
+ printer().printDiagnostic("LLM Configuration:");
+ printer().printDiagnostic(" URL: " + (client.url != null ? client.url
: "(Vertex AI)"));
+ printer().printDiagnostic(" API Type: " + client.apiType);
+ printer().printDiagnostic(" Model: " + client.model);
printMaskedApiKey(client.apiKey);
- printer().println();
+ printer().printDiagnostic("");
}
private void printMaskedApiKey(String key) {
if (key == null || key.isBlank()) {
return;
}
- String masked = "****" + key.substring(Math.max(0, key.length() - 4));
- printer().println(" API Key: " + masked);
+ // the last characters of a long key only: a short one would be shown
whole
+ String masked = key.length() > 8 ? "****" + key.substring(key.length()
- 4) : "****";
+ printer().printDiagnostic(" API Key: " + masked);
}
private void printUsageHelp() {
@@ -227,8 +232,7 @@ public class Explain extends CamelCommand {
printPromptsIfRequested(sysPrompt, userPrompt);
- printer().println("Analyzing route with " + client.model + " (" +
client.apiType + ")...");
- printer().println();
+ printer().printDiagnostic("Analyzing route with " + client.model + "
(" + client.apiType + ")...");
String explanation = client.generate(sysPrompt, userPrompt);
@@ -246,12 +250,12 @@ public class Explain extends CamelCommand {
if (!showPrompt) {
return;
}
- printer().println("--- SYSTEM PROMPT ---");
- printer().println(sysPrompt);
- printer().println("--- USER PROMPT ---");
- printer().println(userPrompt);
- printer().println("--- END PROMPTS ---");
- printer().println();
+ printer().printDiagnostic("--- SYSTEM PROMPT ---");
+ printer().printDiagnostic(sysPrompt);
+ printer().printDiagnostic("--- USER PROMPT ---");
+ printer().printDiagnostic(userPrompt);
+ printer().printDiagnostic("--- END PROMPTS ---");
+ printer().printDiagnostic("");
}
private int handleExplanationResult(String explanation) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Harden.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Harden.java
index 6cfef2eba744..43d38db64081 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Harden.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Harden.java
@@ -37,6 +37,7 @@ import java.util.stream.Stream;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
import org.apache.camel.dsl.jbang.core.commands.ai.RouteComponents;
+import org.apache.camel.dsl.jbang.core.common.CliException;
import org.apache.camel.dsl.jbang.core.common.CliExitCode;
import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
import org.apache.camel.tooling.model.ComponentModel;
@@ -193,6 +194,9 @@ public class Harden extends CamelCommand {
defaultValue = "true", arity = "0..1", fallbackValue = "true")
boolean stream = true;
+ // whether the answer for the current file was printed as it came (reset
per file)
+ private boolean streamed;
+
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
.build();
@@ -203,6 +207,9 @@ public class Harden extends CamelCommand {
@Override
public Integer doCall() throws Exception {
+ if (!"text".equals(format) && !"markdown".equals(format)) {
+ throw CliException.usage("--format must be text or markdown, was:
" + format);
+ }
// check the input before reaching out to an LLM
for (String file : files) {
if (!Files.exists(Path.of(file))) {
@@ -230,20 +237,21 @@ public class Harden extends CamelCommand {
}
private void printConfiguration(String endpoint, String resolvedApiKey) {
- printer().println("LLM Configuration:");
- printer().println(" URL: " + endpoint);
- printer().println(" API Type: " + apiType);
- printer().println(" Model: " + model);
+ printer().printDiagnostic("LLM Configuration:");
+ printer().printDiagnostic(" URL: " + endpoint);
+ printer().printDiagnostic(" API Type: " + apiType);
+ printer().printDiagnostic(" Model: " + model);
printMaskedApiKey(resolvedApiKey);
- printer().println();
+ printer().printDiagnostic("");
}
private void printMaskedApiKey(String key) {
if (key == null || key.isBlank()) {
return;
}
- String masked = "****" + key.substring(Math.max(0, key.length() - 4));
- printer().println(" API Key: " + masked);
+ // the last characters of a long key only: a short one would be shown
whole
+ String masked = key.length() > 8 ? "****" + key.substring(key.length()
- 4) : "****";
+ printer().printDiagnostic(" API Key: " + masked);
}
private void printUsageHelp() {
@@ -370,6 +378,7 @@ public class Harden extends CamelCommand {
}
private int hardenRoute(String file, String endpoint, String
resolvedApiKey) throws Exception {
+ streamed = false;
Path path = Path.of(file);
String routeContent = Files.readString(path);
String ext = Optional.ofNullable(FileUtil.onlyExt(file,
false)).orElse("yaml");
@@ -397,12 +406,12 @@ public class Harden extends CamelCommand {
if (!showPrompt) {
return;
}
- printer().println("--- SYSTEM PROMPT ---");
- printer().println(sysPrompt);
- printer().println("--- USER PROMPT ---");
- printer().println(userPrompt);
- printer().println("--- END PROMPTS ---");
- printer().println();
+ printer().printDiagnostic("--- SYSTEM PROMPT ---");
+ printer().printDiagnostic(sysPrompt);
+ printer().printDiagnostic("--- USER PROMPT ---");
+ printer().printDiagnostic(userPrompt);
+ printer().printDiagnostic("--- END PROMPTS ---");
+ printer().printDiagnostic("");
}
private int handleHardeningResult(String suggestions) {
@@ -410,9 +419,8 @@ public class Harden extends CamelCommand {
printer().printErr("Failed to get security hardening suggestions
from LLM");
return 1;
}
- // With streaming, response was already printed during generation
- // Without streaming, we need to print it now
- if (!stream) {
+ // a streamed answer is printed as it comes; the OpenAI-compatible
service answers in one go, also with --stream
+ if (!streamed) {
printer().println(suggestions);
}
printer().println();
@@ -555,8 +563,7 @@ public class Harden extends CamelCommand {
options.put("temperature", temperature);
request.put("options", options);
- printer().println("Performing security analysis with " + model + "
(Ollama)...");
- printer().println();
+ printer().printDiagnostic("Performing security analysis with " + model
+ " (Ollama)...");
if (stream) {
return sendStreamingRequest(endpoint + "/api/generate", request);
@@ -577,8 +584,7 @@ public class Harden extends CamelCommand {
String apiUrl = normalizeOpenAiUrl(endpoint);
- printer().println("Performing security analysis with " + model + "
(OpenAI-compatible)...");
- printer().println();
+ printer().printDiagnostic("Performing security analysis with " + model
+ " (OpenAI-compatible)...");
JsonObject response = sendRequest(apiUrl, request, resolvedApiKey);
return extractOpenAiContent(response);
@@ -631,6 +637,7 @@ public class Harden extends CamelCommand {
}
StringBuilder fullResponse = new StringBuilder();
+ streamed = true;
response.body().forEach(line -> {
if (line.isBlank()) {
return;
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ExplainTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ExplainTest.java
new file mode 100644
index 000000000000..cd4759be880a
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ExplainTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.dsl.jbang.core.commands;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.stream.Stream;
+
+import org.apache.camel.dsl.jbang.core.common.CliException;
+import org.apache.camel.dsl.jbang.core.common.CliExitCode;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import picocli.CommandLine;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * camel explain: a route explained by an LLM (a local server plays Ollama).
Only the explanation is on stdout.
+ */
+class ExplainTest extends CamelCommandBaseTestSupport {
+
+ private static final String ROUTE = """
+ - route:
+ id: hello
+ from:
+ uri: timer:tick
+ steps:
+ - filter:
+ simple: "${body} != null"
+ steps:
+ - to: kafka:greetings
+ """;
+
+ private static final String EXPLANATION = "The route sends a greeting to
Kafka every second.";
+
+ @TempDir
+ Path dir;
+
+ private Path route;
+ private FakeLlmServer llm;
+
+ @BeforeEach
+ void start() throws Exception {
+ route = Files.writeString(dir.resolve("hello.camel.yaml"), ROUTE);
+ llm = new FakeLlmServer(EXPLANATION);
+ }
+
+ @AfterEach
+ void stop() {
+ llm.close();
+ }
+
+ @Test
+ void explainsTheRoute() throws Exception {
+ assertThat(command(route.toString()).doCall()).isZero();
+
+ // streamed: the parts of the answer as they come
+ assertThat(printer.getStdout()).contains("Explaining: " + route,
EXPLANATION).doesNotContain("LLM Configuration");
+ assertThat(printer.getStderr()).contains("LLM Configuration:", "
Model: test", "Analyzing route with test");
+ assertThat(llm.lastRequest().getString("prompt")).contains("Route
definition:", "kafka:greetings");
+ }
+
+ @Test
+ void explainsTheRouteWithoutStreaming() throws Exception {
+ assertThat(command(route.toString(),
"--stream=false").doCall()).isZero();
+
+ assertThat(printer.getStdout()).contains(EXPLANATION);
+ assertThat(llm.lastRequest().getBoolean("stream")).isFalse();
+ }
+
+ @Test
+ void thePromptsAreOnStderr() throws Exception {
+ assertThat(command(route.toString(), "--show-prompt",
"--format=markdown", "--verbose").doCall()).isZero();
+
+ assertThat(printer.getStderr()).contains("--- SYSTEM PROMPT ---",
"Format output as Markdown",
+ "Include technical details", "--- USER PROMPT ---",
"kafka:greetings");
+ assertThat(printer.getStdout()).doesNotContain("--- SYSTEM PROMPT
---");
+ }
+
+ @Test
+ void catalogContextDescribesTheComponentsAndEips() throws Exception {
+ assertThat(command(route.toString(),
"--catalog-context").doCall()).isZero();
+
+ String prompt = llm.lastRequest().getString("prompt");
+ assertThat(prompt).contains("Reference information:", "- kafka: ", "-
timer: ", "(EIP)");
+ }
+
+ @Test
+ void customSystemPrompt() throws Exception {
+ assertThat(command(route.toString(), "--system-prompt=Answer in one
word.").doCall()).isZero();
+
+ assertThat(llm.lastRequest().getString("system")).isEqualTo("Answer in
one word.");
+ }
+
+ @Test
+ void aShortApiKeyIsNotShown() throws Exception {
+ assertThat(command(route.toString(),
"--api-key=abc").doCall()).isZero();
+
+ assertThat(printer.getStderr()).contains(" API Key:
****").doesNotContain("abc");
+ }
+
+ @Test
+ void aLongApiKeyShowsItsLastCharacters() throws Exception {
+ assertThat(command(route.toString(),
"--api-key=sk-1234567890abcd").doCall()).isZero();
+
+ assertThat(printer.getStderr()).contains(" API Key:
****abcd").doesNotContain("sk-1234567890");
+ }
+
+ @Test
+ void unknownFormatIsAUsageError() {
+ Explain command = command(route.toString(), "--format=json");
+
+ assertThatThrownBy(command::doCall)
+ .isInstanceOfSatisfying(CliException.class,
+ e ->
assertThat(e.getExitCode()).isEqualTo(CliExitCode.USAGE))
+ .hasMessage("--format must be text or markdown, was: json");
+ assertThat(llm.lastRequest()).isNull();
+ }
+
+ @Test
+ void aFileThatDoesNotExistIsNotFound() throws Exception {
+
assertThat(command(dir.resolve("nope.yaml").toString()).doCall()).isEqualTo(CliExitCode.NOT_FOUND);
+
+ assertThat(printer.getStderr()).contains("File not found: ");
+ assertThat(llm.lastRequest()).isNull();
+ }
+
+ private Explain command(String... args) {
+ Explain command = new Explain(new
CamelJBangMain().withPrinter(printer));
+ CommandLine.populateCommand(command,
+ Stream.concat(Arrays.stream(args),
Stream.of("--api-type=ollama", "--url=" + llm.url(), "--model=test"))
+ .toArray(String[]::new));
+ return command;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/FakeLlmServer.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/FakeLlmServer.java
new file mode 100644
index 000000000000..e60017e02118
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/FakeLlmServer.java
@@ -0,0 +1,105 @@
+/*
+ * 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.dsl.jbang.core.commands;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * A local server that plays an LLM for the tests of the AI commands: Ollama
(its models, and the answer of the model,
+ * whole or streamed in two parts) and an OpenAI-compatible chat completion.
It keeps the last question it answered.
+ */
+final class FakeLlmServer implements AutoCloseable {
+
+ private final HttpServer server;
+ private final String answer;
+ private volatile JsonObject lastRequest;
+
+ FakeLlmServer(String answer) throws IOException {
+ this.answer = answer;
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/api/tags", ex -> reply(ex,
"{\"models\":[{\"name\":\"test\",\"size\":1000}]}"));
+ server.createContext("/api/ps", ex -> reply(ex, "{\"models\":[]}"));
+ server.createContext("/api/show", ex -> reply(ex,
"{\"model_info\":{}}"));
+ server.createContext("/api/generate", this::generate);
+ server.createContext("/v1/models", ex -> reply(ex,
"{\"data\":[{\"id\":\"test\"}]}"));
+ server.createContext("/v1/chat/completions", this::chat);
+ server.start();
+ }
+
+ String url() {
+ return "http://127.0.0.1:" + server.getAddress().getPort();
+ }
+
+ /**
+ * The last question the server answered, as the client sent it.
+ */
+ JsonObject lastRequest() {
+ return lastRequest;
+ }
+
+ @Override
+ public void close() {
+ server.stop(0);
+ }
+
+ private void generate(HttpExchange exchange) throws IOException {
+ JsonObject request = read(exchange);
+ if (request.getBooleanOrDefault("stream", false)) {
+ // the answer in two parts, a JSON document per line
+ int half = answer.length() / 2;
+ String first = new JsonObject(Map.of("response",
answer.substring(0, half), "done", false)).toJson();
+ String last = new JsonObject(Map.of("response",
answer.substring(half), "done", true)).toJson();
+ reply(exchange, first + "\n" + last + "\n");
+ } else {
+ reply(exchange, new JsonObject(Map.of("response", answer, "done",
true)).toJson());
+ }
+ }
+
+ private void chat(HttpExchange exchange) throws IOException {
+ read(exchange);
+ JsonObject message = new JsonObject(Map.of("role", "assistant",
"content", answer));
+ JsonObject choice = new JsonObject(Map.of("index", 0, "message",
message));
+ reply(exchange, new JsonObject(Map.of("choices", new
JsonArray(List.of(choice)))).toJson());
+ }
+
+ private JsonObject read(HttpExchange exchange) throws IOException {
+ String body = new String(exchange.getRequestBody().readAllBytes(),
StandardCharsets.UTF_8);
+ JsonObject request = Jsoner.deserialize(body, new JsonObject());
+ lastRequest = request;
+ return request;
+ }
+
+ private static void reply(HttpExchange exchange, String body) throws
IOException {
+ byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, bytes.length);
+ try (OutputStream out = exchange.getResponseBody()) {
+ out.write(bytes);
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/HardenTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/HardenTest.java
new file mode 100644
index 000000000000..183710a20c60
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/HardenTest.java
@@ -0,0 +1,148 @@
+/*
+ * 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.dsl.jbang.core.commands;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.stream.Stream;
+
+import org.apache.camel.dsl.jbang.core.common.CliException;
+import org.apache.camel.dsl.jbang.core.common.CliExitCode;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import picocli.CommandLine;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * camel harden: security suggestions for a route by an LLM (a local server
plays Ollama, or an OpenAI-compatible
+ * service). Only the suggestions are on stdout.
+ */
+class HardenTest extends CamelCommandBaseTestSupport {
+
+ private static final String ROUTE = """
+ - route:
+ id: upload
+ from:
+ uri: platform-http:/upload
+ steps:
+ - to: ftp:files.example.com/in?username=bob&password=secret
+ """;
+
+ private static final String SUGGESTIONS = "Use SFTP instead of FTP, and
keep the password in a vault.";
+
+ @TempDir
+ Path dir;
+
+ private Path route;
+ private FakeLlmServer llm;
+
+ @BeforeEach
+ void start() throws Exception {
+ route = Files.writeString(dir.resolve("upload.camel.yaml"), ROUTE);
+ llm = new FakeLlmServer(SUGGESTIONS);
+ }
+
+ @AfterEach
+ void stop() {
+ llm.close();
+ }
+
+ @Test
+ void suggestionsFromOllama() throws Exception {
+ assertThat(command(route.toString()).doCall()).isZero();
+
+ assertThat(printer.getStdout()).contains("Security Hardening Analysis:
" + route, SUGGESTIONS)
+ .doesNotContain("LLM Configuration");
+ assertThat(printer.getStderr()).contains("LLM Configuration:",
"Performing security analysis with test (Ollama)");
+
assertThat(llm.lastRequest().getString("prompt")).contains("ftp:files.example.com");
+ }
+
+ @Test
+ void suggestionsFromAnOpenAiCompatibleService() throws Exception {
+ assertThat(command(route.toString(), "--api-type=openai",
"--api-key=sk-1234567890abcd").doCall()).isZero();
+
+ assertThat(printer.getStdout()).contains(SUGGESTIONS);
+ assertThat(printer.getStderr()).contains("(OpenAI-compatible)", " API
Key: ****abcd");
+ JsonObject request = llm.lastRequest();
+ assertThat(request.getString("model")).isEqualTo("test");
+ assertThat(request.toJson()).contains("ftp:files.example.com");
+ }
+
+ @Test
+ void suggestionsForEachFile() throws Exception {
+ Path other = Files.writeString(dir.resolve("other.camel.yaml"),
ROUTE.replace("upload", "other"));
+
+ assertThat(command(route.toString(), other.toString(),
"--api-type=openai").doCall()).isZero();
+
+ String out = printer.getStdout();
+ assertThat(out).contains("Security Hardening Analysis: " + route,
"Security Hardening Analysis: " + other);
+ // the answer of each file, also after a file before it
+
assertThat(out.indexOf(SUGGESTIONS)).isNotEqualTo(out.lastIndexOf(SUGGESTIONS));
+ }
+
+ @Test
+ void streamedSuggestionsForEachFile() throws Exception {
+ Path other = Files.writeString(dir.resolve("other.camel.yaml"),
ROUTE.replace("upload", "other"));
+
+ assertThat(command(route.toString(),
other.toString()).doCall()).isZero();
+
+ String out = printer.getStdout();
+
assertThat(out.indexOf(SUGGESTIONS)).isNotEqualTo(-1).isNotEqualTo(out.lastIndexOf(SUGGESTIONS));
+ }
+
+ @Test
+ void thePromptsAreOnStderr() throws Exception {
+ assertThat(command(route.toString(), "--show-prompt",
"--stream=false").doCall()).isZero();
+
+ assertThat(printer.getStderr()).contains("--- SYSTEM PROMPT ---", "---
USER PROMPT ---");
+
assertThat(printer.getStdout()).contains(SUGGESTIONS).doesNotContain("---
SYSTEM PROMPT ---");
+ }
+
+ @Test
+ void anUnreachableUrlIsNotReplacedByAnotherLlm() throws Exception {
+ Harden command = new Harden(new CamelJBangMain().withPrinter(printer));
+ CommandLine.populateCommand(command, route.toString(),
"--url=http://127.0.0.1:1", "--model=test");
+
+ // the route is not sent to the Ollama of the host
+ assertThat(command.doCall()).isEqualTo(CliExitCode.FAILURE);
+ assertThat(printer.getStderr()).contains("Cannot connect to LLM
service at: http://127.0.0.1:1");
+ }
+
+ @Test
+ void unknownFormatIsAUsageError() {
+ Harden command = command(route.toString(), "--format=json");
+
+ assertThatThrownBy(command::doCall)
+ .isInstanceOfSatisfying(CliException.class,
+ e ->
assertThat(e.getExitCode()).isEqualTo(CliExitCode.USAGE))
+ .hasMessage("--format must be text or markdown, was: json");
+ }
+
+ private Harden command(String... args) {
+ Harden command = new Harden(new CamelJBangMain().withPrinter(printer));
+ CommandLine.populateCommand(command,
+ Stream.concat(Arrays.stream(args), Stream.of("--url=" +
llm.url(), "--model=test"))
+ .toArray(String[]::new));
+ return command;
+ }
+}