You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hop.apache.org by ha...@apache.org on 2026/03/20 18:04:15 UTC

(hop) branch main updated: [SONAR] fix security issues and improve coverage (#6843)

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

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


The following commit(s) were added to refs/heads/main by this push:
     new 0bc234116c [SONAR] fix security issues and improve coverage (#6843)
0bc234116c is described below

commit 0bc234116c3096e465217709b26b7457bdc31fbc
Author: Hans Van Akelyen <ha...@gmail.com>
AuthorDate: Fri Mar 20 19:04:09 2026 +0100

    [SONAR] fix security issues and improve coverage (#6843)
---
 .../java/org/apache/hop/www/AddExportServlet.java  |  24 +--
 .../java/org/apache/hop/www/BaseHttpServlet.java   |  68 ++++++++-
 .../java/org/apache/hop/www/BodyHttpServlet.java   |  25 ++-
 .../apache/hop/www/GetExecutionInfoServlet.java    |  18 ++-
 .../apache/hop/www/GetPipelineImageServlet.java    |  12 +-
 .../apache/hop/www/GetPipelineStatusServlet.java   |  14 +-
 .../java/org/apache/hop/www/GetRootServlet.java    |   2 +-
 .../java/org/apache/hop/www/GetStatusServlet.java  |  84 ++++++-----
 .../apache/hop/www/GetWorkflowImageServlet.java    |  24 ++-
 .../apache/hop/www/GetWorkflowStatusServlet.java   |  10 +-
 .../java/org/apache/hop/www/HopServerServlet.java  |   8 +-
 .../org/apache/hop/www/HopServerSingleton.java     |   4 +-
 .../main/java/org/apache/hop/www/PipelineMap.java  |   6 +-
 .../org/apache/hop/www/SniffTransformServlet.java  |  12 +-
 .../main/java/org/apache/hop/www/WebResult.java    |   2 +
 .../java/org/apache/hop/www/WebServiceServlet.java |  11 +-
 .../apache/hop/www/jaxrs/HopServerResource.java    |   4 +
 .../org/apache/hop/www/jaxrs/PipelineResource.java |   4 +
 .../org/apache/hop/www/jaxrs/WorkflowResource.java |   4 +
 .../org/apache/hop/www/BaseHttpServletTest.java    | 167 +++++++++++++++++++++
 .../org/apache/hop/www/BodyHttpServletTest.java    | 146 ++++++++++++++++++
 ...va => DefaultWebServerShutdownHandlerTest.java} |  25 +--
 ...t.java => GetExecutionInfoServletTypeTest.java} |  29 +---
 .../hop/www/GetPipelineImageServletTest.java       |  72 +++++++++
 .../org/apache/hop/www/GetRootServletTest.java     |  22 ++-
 .../hop/www/GetWorkflowImageServletTest.java       |  72 +++++++++
 ...est.java => HopServerCommandExceptionTest.java} |  35 ++---
 .../apache/hop/www/HopServerObjectEntryTest.java   |  64 ++++++++
 .../hop/www/HopServerPluginServletSmokeTest.java   |  55 +++++++
 .../org/apache/hop/www/HopServerServletTest.java   | 124 +++++++++++++++
 .../org/apache/hop/www/HopServerSingletonTest.java |  58 +++++++
 .../org/apache/hop/www/HopServerStatusTest.java    |  55 +++++++
 ...ServletTest.java => IHopServerServletTest.java} |  26 +---
 .../java/org/apache/hop/www/PipelineMapTest.java   |  82 ++++++++++
 .../apache/hop/www/SocketPortAllocationTest.java   |  62 ++++++++
 .../org/apache/hop/www/SslConfigurationTest.java   |  83 ++++++++++
 ...ervletTest.java => StatusServletUtilsTest.java} |  27 +---
 .../java/org/apache/hop/www/WebResultTest.java     |  11 ++
 .../apache/hop/www/WebServerShutdownHookTest.java  |  65 ++++++++
 .../java/org/apache/hop/www/WorkflowMapTest.java   | 102 +++++++++++++
 .../java/org/apache/hop/www/jaxrs/NVPairTest.java  |  44 ++++++
 .../apache/hop/www/jaxrs/PipelineStatusTest.java   |  48 ++++++
 .../org/apache/hop/www/jaxrs/ServerStatusTest.java |  41 +++++
 .../apache/hop/www/jaxrs/WorkflowStatusTest.java   |  36 +++++
 .../org/apache/hop/www/service/WebServiceTest.java |  66 ++++++++
 .../apache/hop/www/async/AsyncStatusServlet.java   |  16 +-
 46 files changed, 1764 insertions(+), 205 deletions(-)

diff --git a/engine/src/main/java/org/apache/hop/www/AddExportServlet.java b/engine/src/main/java/org/apache/hop/www/AddExportServlet.java
index 2f20374d71..bdc32c38bf 100644
--- a/engine/src/main/java/org/apache/hop/www/AddExportServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/AddExportServlet.java
@@ -28,7 +28,6 @@ import java.io.Serial;
 import java.util.UUID;
 import org.apache.commons.lang.StringEscapeUtils;
 import org.apache.commons.vfs2.FileObject;
-import org.apache.hop.core.Const;
 import org.apache.hop.core.annotations.HopServerServlet;
 import org.apache.hop.core.logging.LoggingObjectType;
 import org.apache.hop.core.logging.SimpleLoggingObject;
@@ -93,10 +92,6 @@ public class AddExportServlet extends BaseHttpServlet implements IHopServerPlugi
     if (out == null) {
       return;
     }
-    InputStream in = request.getInputStream(); // read from the client
-    if (log.isDetailed()) {
-      logDetailed("Encoding: " + request.getCharacterEncoding());
-    }
 
     boolean isWorkflow =
         TYPE_WORKFLOW.equalsIgnoreCase(
@@ -105,13 +100,18 @@ public class AddExportServlet extends BaseHttpServlet implements IHopServerPlugi
         StringEscapeUtils.escapeHtml(request.getParameter(PARAMETER_LOAD)); // the resource to load
 
     response.setContentType("text/xml");
-    out.print(XmlHandler.getXmlHeader());
-
     response.setStatus(HttpServletResponse.SC_OK);
 
     OutputStream outputStream = null;
 
     try {
+      InputStream in = request.getInputStream(); // read from the client
+      if (log.isDetailed()) {
+        logDetailed("Encoding: " + request.getCharacterEncoding());
+      }
+
+      out.print(XmlHandler.getXmlHeader());
+
       FileObject tempFile =
           HopVfs.createTempFile("export", ".zip", System.getProperty("java.io.tmpdir"));
       outputStream = HopVfs.getOutputStream(tempFile, false);
@@ -235,10 +235,16 @@ public class AddExportServlet extends BaseHttpServlet implements IHopServerPlugi
 
       out.println(new WebResult(WebResult.STRING_OK, fileUrl, serverObjectId));
     } catch (Exception ex) {
-      out.println(new WebResult(WebResult.STRING_ERROR, Const.getStackTracker(ex)));
+      logError("Add export failed", ex);
+      out.println(
+          new WebResult(WebResult.STRING_ERROR, "Export failed. See server log for details."));
     } finally {
       if (outputStream != null) {
-        outputStream.close();
+        try {
+          outputStream.close();
+        } catch (IOException e) {
+          logError("Failed to close export output stream", e);
+        }
       }
     }
   }
diff --git a/engine/src/main/java/org/apache/hop/www/BaseHttpServlet.java b/engine/src/main/java/org/apache/hop/www/BaseHttpServlet.java
index 913b0f8b25..42c1c05f2b 100644
--- a/engine/src/main/java/org/apache/hop/www/BaseHttpServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/BaseHttpServlet.java
@@ -23,8 +23,10 @@ import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import java.io.BufferedReader;
 import java.io.IOException;
+import java.io.OutputStream;
 import java.io.PrintWriter;
 import java.io.Serial;
+import java.nio.charset.StandardCharsets;
 import lombok.Getter;
 import lombok.Setter;
 import org.apache.hop.core.Const;
@@ -32,6 +34,7 @@ import org.apache.hop.core.logging.ILogChannel;
 import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.xml.XmlHandler;
 import org.apache.http.entity.ContentType;
 
 public class BaseHttpServlet extends HttpServlet {
@@ -43,7 +46,8 @@ public class BaseHttpServlet extends HttpServlet {
 
   @Setter @Getter protected HopServerConfig serverConfig;
   protected IVariables variables;
-  protected boolean supportGraphicEnvironment;
+
+  @Setter @Getter protected boolean supportGraphicEnvironment;
 
   @Setter @Getter private boolean jettyMode = false;
 
@@ -197,6 +201,68 @@ public class BaseHttpServlet extends HttpServlet {
     }
   }
 
+  /**
+   * Log server-side and return a {@link WebResult} error to the client in the requested XML or JSON
+   * shape. Use {@code writer} when the response writer is already acquired for this request;
+   * otherwise pass {@code null} and the output stream is used.
+   */
+  protected void writeXmlOrJsonApiError(
+      HttpServletResponse response,
+      PrintWriter writer,
+      boolean useXml,
+      boolean useJson,
+      String logMessage,
+      Throwable cause) {
+    log.logError(logMessage, cause);
+    final String clientMessage = "Unable to complete request.";
+    if (response.isCommitted()) {
+      return;
+    }
+    try {
+      response.resetBuffer();
+    } catch (IllegalStateException e) {
+      sendSafeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, clientMessage);
+      return;
+    }
+    if (!useXml && !useJson) {
+      sendSafeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, clientMessage);
+      return;
+    }
+    setResponseFormat(response, useXml, useJson);
+    response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+    WebResult errorResult = new WebResult(WebResult.STRING_ERROR, clientMessage);
+    try {
+      if (useXml) {
+        String payload = XmlHandler.getXmlHeader(Const.XML_ENCODING) + errorResult.getXml();
+        byte[] bytes = payload.getBytes(StandardCharsets.UTF_8);
+        if (writer != null) {
+          writer.write(payload);
+          writer.flush();
+        } else {
+          OutputStream os = response.getOutputStream();
+          response.setContentLength(bytes.length);
+          os.write(bytes);
+          os.flush();
+        }
+      } else {
+        String payload = errorResult.getJson();
+        byte[] bytes = payload.getBytes(StandardCharsets.UTF_8);
+        if (writer != null) {
+          writer.write(payload);
+          writer.flush();
+        } else {
+          OutputStream os = response.getOutputStream();
+          response.setContentLength(bytes.length);
+          os.write(bytes);
+          os.flush();
+        }
+      }
+    } catch (IOException e) {
+      log.logError("Failed to write API error response", e);
+      sendSafeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, clientMessage);
+    }
+  }
+
   public PipelineMap getPipelineMap() {
     if (pipelineMap == null) {
       return HopServerSingleton.getInstance().getPipelineMap();
diff --git a/engine/src/main/java/org/apache/hop/www/BodyHttpServlet.java b/engine/src/main/java/org/apache/hop/www/BodyHttpServlet.java
index 0e54a97207..239f6690eb 100644
--- a/engine/src/main/java/org/apache/hop/www/BodyHttpServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/BodyHttpServlet.java
@@ -19,10 +19,11 @@ package org.apache.hop.www;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import java.io.IOException;
+import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.io.Serial;
+import java.nio.charset.StandardCharsets;
 import org.apache.commons.io.IOUtils;
-import org.apache.commons.lang.exception.ExceptionUtils;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.xml.XmlHandler;
@@ -53,7 +54,18 @@ public abstract class BodyHttpServlet extends BaseHttpServlet implements IHopSer
 
     boolean useXML = useXML(request);
     boolean useJson = isJsonRequest(request);
-    PrintWriter out = new PrintWriter(response.getOutputStream());
+
+    final PrintWriter out;
+    try {
+      out =
+          new PrintWriter(
+              new OutputStreamWriter(response.getOutputStream(), StandardCharsets.UTF_8), true);
+    } catch (IOException e) {
+      logError("Failed to open servlet response stream", e);
+      sendSafeError(
+          response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unable to process request.");
+      return;
+    }
 
     try {
 
@@ -76,14 +88,15 @@ public abstract class BodyHttpServlet extends BaseHttpServlet implements IHopSer
       }
 
     } catch (Exception e) {
-      String st = ExceptionUtils.getFullStackTrace(e);
+      logError("Servlet body generation failed", e);
+      final String clientMessage = "Request failed. See server log for details.";
       if (useXML) {
-        out.println(new WebResult(WebResult.STRING_ERROR, st).getXml());
+        out.println(new WebResult(WebResult.STRING_ERROR, clientMessage).getXml());
       } else if (useJson) {
-        out.println(new WebResult(WebResult.STRING_ERROR, st).getJson());
+        out.println(new WebResult(WebResult.STRING_ERROR, clientMessage).getJson());
       } else {
         out.println("<p><pre>");
-        out.println(Encode.forHtml(st));
+        out.println(Encode.forHtml(clientMessage));
         out.println("</pre>");
       }
     } finally {
diff --git a/engine/src/main/java/org/apache/hop/www/GetExecutionInfoServlet.java b/engine/src/main/java/org/apache/hop/www/GetExecutionInfoServlet.java
index 396e6733b9..013f662c88 100644
--- a/engine/src/main/java/org/apache/hop/www/GetExecutionInfoServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/GetExecutionInfoServlet.java
@@ -17,7 +17,6 @@
 
 package org.apache.hop.www;
 
-import jakarta.servlet.ServletException;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import jakarta.xml.bind.DataBindingException;
@@ -76,8 +75,7 @@ public class GetExecutionInfoServlet extends BaseHttpServlet implements IHopServ
   }
 
   @Override
-  public void doGet(HttpServletRequest request, HttpServletResponse response)
-      throws ServletException, IOException {
+  public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
     if (isJettyMode() && !request.getContextPath().startsWith(CONTEXT_PATH)) {
       return;
     }
@@ -301,14 +299,18 @@ public class GetExecutionInfoServlet extends BaseHttpServlet implements IHopServ
         iLocation.close();
       }
     } catch (Exception e) {
-      String message = Const.getStackTracker(e);
+      logError("Execution info request failed", e);
+      response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+      String clientMessage =
+          e instanceof HopException
+              ? Const.NVL(e.getMessage(), "Request failed.")
+              : "Unable to retrieve execution information.";
       try {
-        HopJson.newMapper().writeValue(out, message);
+        HopJson.newMapper().writeValue(out, clientMessage);
       } catch (IOException | DataBindingException ex) {
-        throw new ServletException(
-            "Error writing execution state as JSON to servlet output stream", ex);
+        logError("Failed to write execution info error response as JSON", ex);
+        sendSafeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, clientMessage);
       }
-      response.setStatus(500);
     }
   }
 
diff --git a/engine/src/main/java/org/apache/hop/www/GetPipelineImageServlet.java b/engine/src/main/java/org/apache/hop/www/GetPipelineImageServlet.java
index ccd1b8a12e..9e4f5cd4c6 100644
--- a/engine/src/main/java/org/apache/hop/www/GetPipelineImageServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/GetPipelineImageServlet.java
@@ -96,10 +96,18 @@ public class GetPipelineImageServlet extends BaseHttpServlet implements IHopServ
         out.write(svgStream.toByteArray());
       }
     } catch (Exception e) {
-      throw new IOException("Error building SVG image of pipleine", e);
+      logError("Error building SVG image of pipeline", e);
+      sendSafeError(
+          response,
+          HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+          "Unable to generate pipeline image.");
     } finally {
       if (svgStream != null) {
-        svgStream.close();
+        try {
+          svgStream.close();
+        } catch (IOException e) {
+          logError("Failed to close pipeline SVG buffer stream", e);
+        }
       }
     }
   }
diff --git a/engine/src/main/java/org/apache/hop/www/GetPipelineStatusServlet.java b/engine/src/main/java/org/apache/hop/www/GetPipelineStatusServlet.java
index 61ffdddd11..8c605cd02b 100644
--- a/engine/src/main/java/org/apache/hop/www/GetPipelineStatusServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/GetPipelineStatusServlet.java
@@ -187,8 +187,14 @@ public class GetPipelineStatusServlet extends BaseHttpServlet implements IHopSer
           }
           out.flush();
           response.flushBuffer();
-        } catch (HopException e) {
-          throw new ServletException("Unable to get the pipeline status in XML or JSON format", e);
+        } catch (HopException | IOException e) {
+          writeXmlOrJsonApiError(
+              response,
+              null,
+              useXml,
+              useJson,
+              "Unable to get the pipeline status in XML or JSON format",
+              e);
         }
       } else {
         PrintWriter out = getSafeWriter(response);
@@ -561,8 +567,10 @@ public class GetPipelineStatusServlet extends BaseHttpServlet implements IHopSer
           out.println("  pipelinelog.scrollTop=pipelinelog.scrollHeight; ");
           out.println("</script> ");
         } catch (Exception ex) {
+          logError("Error rendering pipeline status HTML page", ex);
           out.println("<pre>");
-          out.println(Encode.forHtml(Const.getStackTracker(ex)));
+          out.println(
+              Encode.forHtml("Unable to display pipeline status. See server log for details."));
           out.println("</pre>");
         }
 
diff --git a/engine/src/main/java/org/apache/hop/www/GetRootServlet.java b/engine/src/main/java/org/apache/hop/www/GetRootServlet.java
index 47e5d70c0c..dfdc00eace 100644
--- a/engine/src/main/java/org/apache/hop/www/GetRootServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/GetRootServlet.java
@@ -38,7 +38,7 @@ public class GetRootServlet extends BaseHttpServlet implements IHopServerPlugin
   public void doGet(HttpServletRequest request, HttpServletResponse response)
       throws ServletException, IOException {
     if (isJettyMode() && !request.getRequestURI().equals(CONTEXT_PATH)) {
-      response.sendError(HttpServletResponse.SC_NOT_FOUND);
+      sendSafeError(response, HttpServletResponse.SC_NOT_FOUND, "Not found.");
       return;
     }
 
diff --git a/engine/src/main/java/org/apache/hop/www/GetStatusServlet.java b/engine/src/main/java/org/apache/hop/www/GetStatusServlet.java
index d774013db6..f63870f037 100644
--- a/engine/src/main/java/org/apache/hop/www/GetStatusServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/GetStatusServlet.java
@@ -117,50 +117,53 @@ public class GetStatusServlet extends BaseHttpServlet implements IHopServerPlugi
     List<HopServerObjectEntry> actions = getWorkflowMap().getWorkflowObjects();
 
     if (useXml || useJson) {
-      HopServerStatus serverStatus = new HopServerStatus();
-      serverStatus.setStatusDescription("Online");
-
-      getSystemInfo(serverStatus);
-
-      for (HopServerObjectEntry entry : pipelineEntries) {
-        IPipelineEngine<PipelineMeta> pipeline = getPipelineMap().getPipeline(entry);
-        String statusDescription = pipeline.getStatusDescription();
-
-        HopServerPipelineStatus pipelineStatus =
-            new HopServerPipelineStatus(entry.getName(), entry.getId(), statusDescription);
-        pipelineStatus.setLogDate(new Date());
-        pipelineStatus.setExecutionStartDate(pipeline.getExecutionStartDate());
-        pipelineStatus.setExecutionEndDate(pipeline.getExecutionEndDate());
-        pipelineStatus.setPaused(pipeline.isPaused());
-        serverStatus.getPipelineStatusList().add(pipelineStatus);
-      }
+      try {
+        HopServerStatus serverStatus = new HopServerStatus();
+        serverStatus.setStatusDescription("Online");
 
-      for (HopServerObjectEntry entry : actions) {
-        IWorkflowEngine<WorkflowMeta> workflow = getWorkflowMap().getWorkflow(entry);
-        String status = workflow.getStatusDescription();
-        HopServerWorkflowStatus workflowStatus =
-            new HopServerWorkflowStatus(entry.getName(), entry.getId(), status);
-        workflowStatus.setLogDate(new Date());
-        workflowStatus.setExecutionStartDate(workflow.getExecutionStartDate());
-        workflowStatus.setExecutionEndDate(workflow.getExecutionEndDate());
-        serverStatus.getWorkflowStatusList().add(workflowStatus);
-      }
+        getSystemInfo(serverStatus);
+
+        for (HopServerObjectEntry entry : pipelineEntries) {
+          IPipelineEngine<PipelineMeta> pipeline = getPipelineMap().getPipeline(entry);
+          String statusDescription = pipeline.getStatusDescription();
+
+          HopServerPipelineStatus pipelineStatus =
+              new HopServerPipelineStatus(entry.getName(), entry.getId(), statusDescription);
+          pipelineStatus.setLogDate(new Date());
+          pipelineStatus.setExecutionStartDate(pipeline.getExecutionStartDate());
+          pipelineStatus.setExecutionEndDate(pipeline.getExecutionEndDate());
+          pipelineStatus.setPaused(pipeline.isPaused());
+          serverStatus.getPipelineStatusList().add(pipelineStatus);
+        }
+
+        for (HopServerObjectEntry entry : actions) {
+          IWorkflowEngine<WorkflowMeta> workflow = getWorkflowMap().getWorkflow(entry);
+          String status = workflow.getStatusDescription();
+          HopServerWorkflowStatus workflowStatus =
+              new HopServerWorkflowStatus(entry.getName(), entry.getId(), status);
+          workflowStatus.setLogDate(new Date());
+          workflowStatus.setExecutionStartDate(workflow.getExecutionStartDate());
+          workflowStatus.setExecutionEndDate(workflow.getExecutionEndDate());
+          serverStatus.getWorkflowStatusList().add(workflowStatus);
+        }
 
-      if (useXml) {
-        // XML
-        try {
+        if (useXml) {
           out.print(XmlHandler.getXmlHeader(Const.XML_ENCODING));
           out.println(serverStatus.getXml());
-        } catch (HopException e) {
-          throw new ServletException("Unable to get the server status in XML format", e);
+        } else {
+          ObjectMapper mapper = HopJson.newMapper();
+          String jsonString =
+              mapper.writerWithDefaultPrettyPrinter().writeValueAsString(serverStatus);
+          out.println(jsonString);
         }
-      } else {
-        // JSON
-        //
-        ObjectMapper mapper = HopJson.newMapper();
-        String jsonString =
-            mapper.writerWithDefaultPrettyPrinter().writeValueAsString(serverStatus);
-        out.println(jsonString);
+      } catch (HopException | IOException e) {
+        writeXmlOrJsonApiError(
+            response,
+            out,
+            useXml,
+            useJson,
+            "Unable to get the server status in XML or JSON format",
+            e);
       }
     } else {
       out.println("<HTML>");
@@ -651,8 +654,9 @@ public class GetStatusServlet extends BaseHttpServlet implements IHopServerPlugi
         out.print(CONST_DIV); // end div
 
       } catch (Exception ex) {
+        logError("Error rendering server status HTML page", ex);
         out.println("<pre>");
-        ex.printStackTrace(out);
+        out.println("Unable to display server status. See server log for details.");
         out.println("</pre>");
       }
 
diff --git a/engine/src/main/java/org/apache/hop/www/GetWorkflowImageServlet.java b/engine/src/main/java/org/apache/hop/www/GetWorkflowImageServlet.java
index a3ee599cec..61ec0463d5 100644
--- a/engine/src/main/java/org/apache/hop/www/GetWorkflowImageServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/GetWorkflowImageServlet.java
@@ -80,8 +80,6 @@ public class GetWorkflowImageServlet extends BaseHttpServlet implements IHopServ
       workflow = getWorkflowMap().getWorkflow(entry);
     }
 
-    ByteArrayOutputStream svgStream = null;
-
     try {
       if (workflow != null) {
 
@@ -94,23 +92,21 @@ public class GetWorkflowImageServlet extends BaseHttpServlet implements IHopServ
         //
         String svgXml =
             WorkflowSvgPainter.generateWorkflowSvg(workflow.getWorkflowMeta(), 1.0f, variables);
-        svgStream = new ByteArrayOutputStream();
-        try {
+        try (ByteArrayOutputStream svgStream = new ByteArrayOutputStream()) {
           svgStream.write(svgXml.getBytes(StandardCharsets.UTF_8));
-        } finally {
           svgStream.flush();
-        }
-        response.setContentLength(svgStream.size());
+          response.setContentLength(svgStream.size());
 
-        OutputStream out = response.getOutputStream();
-        out.write(svgStream.toByteArray());
+          OutputStream out = response.getOutputStream();
+          out.write(svgStream.toByteArray());
+        }
       }
     } catch (Exception e) {
-      e.printStackTrace();
-    } finally {
-      if (svgStream != null) {
-        svgStream.close();
-      }
+      logError("Error building SVG image of workflow", e);
+      sendSafeError(
+          response,
+          HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+          "Unable to generate workflow image.");
     }
   }
 
diff --git a/engine/src/main/java/org/apache/hop/www/GetWorkflowStatusServlet.java b/engine/src/main/java/org/apache/hop/www/GetWorkflowStatusServlet.java
index ec3a5c2985..472400c14d 100644
--- a/engine/src/main/java/org/apache/hop/www/GetWorkflowStatusServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/GetWorkflowStatusServlet.java
@@ -192,8 +192,14 @@ public class GetWorkflowStatusServlet extends BaseHttpServlet implements IHopSer
           out.flush();
 
           response.flushBuffer();
-        } catch (HopException e) {
-          throw new ServletException("Unable to get the workflow status in XML or JSON format", e);
+        } catch (HopException | IOException e) {
+          writeXmlOrJsonApiError(
+              response,
+              null,
+              useXml,
+              useJson,
+              "Unable to get the workflow status in XML or JSON format",
+              e);
         }
       } else {
 
diff --git a/engine/src/main/java/org/apache/hop/www/HopServerServlet.java b/engine/src/main/java/org/apache/hop/www/HopServerServlet.java
index ee44a0e646..447066338f 100644
--- a/engine/src/main/java/org/apache/hop/www/HopServerServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/HopServerServlet.java
@@ -87,15 +87,17 @@ public class HopServerServlet extends HttpServlet {
       try {
         plugin.doGet(req, resp);
       } catch (ServletException e) {
-        throw e;
+        log.logError("Hop server plugin request failed", e);
+        sendSafeError(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Plugin request failed.");
       } catch (Exception e) {
-        throw new ServletException(e);
+        log.logError("Hop server plugin request failed", e);
+        sendSafeError(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Plugin request failed.");
       }
     } else {
       if (log.isDebug()) {
         log.logDebug("Unable to find Hop Server Plugin for key: /hop" + req.getPathInfo());
       }
-      resp.sendError(404);
+      sendSafeError(resp, HttpServletResponse.SC_NOT_FOUND, "Not found.");
     }
   }
 
diff --git a/engine/src/main/java/org/apache/hop/www/HopServerSingleton.java b/engine/src/main/java/org/apache/hop/www/HopServerSingleton.java
index dfd754c057..27138d7943 100644
--- a/engine/src/main/java/org/apache/hop/www/HopServerSingleton.java
+++ b/engine/src/main/java/org/apache/hop/www/HopServerSingleton.java
@@ -136,7 +136,7 @@ public class HopServerSingleton {
                               Math.floor(
                                   (System.currentTimeMillis()
                                           - pipeline.getExecutionStartDate().getTime())
-                                      / 60000);
+                                      / 60000.0);
                       if (diffInMinutes >= objectTimeout) {
                         // Let's remove this from the pipeline map...
                         //
@@ -178,7 +178,7 @@ public class HopServerSingleton {
                               Math.floor(
                                   (System.currentTimeMillis()
                                           - workflow.getExecutionStartDate().getTime())
-                                      / 60000);
+                                      / 60000.0);
                       if (diffInMinutes >= objectTimeout) {
                         // Let's remove this from the workflow map...
                         //
diff --git a/engine/src/main/java/org/apache/hop/www/PipelineMap.java b/engine/src/main/java/org/apache/hop/www/PipelineMap.java
index 195af37475..3f8e7dbf4d 100644
--- a/engine/src/main/java/org/apache/hop/www/PipelineMap.java
+++ b/engine/src/main/java/org/apache/hop/www/PipelineMap.java
@@ -106,7 +106,11 @@ public class PipelineMap {
    * @return the pipeline configuration with the specified entry
    */
   public PipelineConfiguration getConfiguration(HopServerObjectEntry entry) {
-    return pipelineMap.get(entry).getConfiguration();
+    PipelineData pipelineData = pipelineMap.get(entry);
+    if (pipelineData != null) {
+      return pipelineData.getConfiguration();
+    }
+    return null;
   }
 
   /**
diff --git a/engine/src/main/java/org/apache/hop/www/SniffTransformServlet.java b/engine/src/main/java/org/apache/hop/www/SniffTransformServlet.java
index 73f2e3ac46..0d208b3a39 100644
--- a/engine/src/main/java/org/apache/hop/www/SniffTransformServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/SniffTransformServlet.java
@@ -63,6 +63,7 @@ public class SniffTransformServlet extends BaseHttpServlet implements IHopServer
   }
 
   @Override
+  @SuppressWarnings("java:S2142")
   public void doGet(HttpServletRequest request, HttpServletResponse response)
       throws ServletException, IOException {
     if (isJettyMode() && !request.getContextPath().startsWith(CONTEXT_PATH)) {
@@ -200,7 +201,16 @@ public class SniffTransformServlet extends BaseHttpServlet implements IHopServer
           response.setContentType("text/xml");
           response.setCharacterEncoding(Const.XML_ENCODING);
           out.print(XmlHandler.getXmlHeader(Const.XML_ENCODING));
-          out.println(rowBuffer.getXml());
+          try {
+            out.println(rowBuffer.getXml());
+          } catch (IOException xmlEx) {
+            logError("Failed to serialize sniff rows to XML", xmlEx);
+            out.println(
+                new WebResult(
+                        WebResult.STRING_ERROR,
+                        "Failed to serialize rows to XML: " + xmlEx.getMessage())
+                    .getXml());
+          }
 
         } else if (useJson) {
 
diff --git a/engine/src/main/java/org/apache/hop/www/WebResult.java b/engine/src/main/java/org/apache/hop/www/WebResult.java
index 362295041e..f872f77759 100644
--- a/engine/src/main/java/org/apache/hop/www/WebResult.java
+++ b/engine/src/main/java/org/apache/hop/www/WebResult.java
@@ -17,6 +17,7 @@
 
 package org.apache.hop.www;
 
+import com.fasterxml.jackson.annotation.JsonIgnore;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.exception.HopXmlException;
@@ -68,6 +69,7 @@ public class WebResult {
     return xml.toString();
   }
 
+  @JsonIgnore
   public String getJson() {
     try {
       return HopJson.newMapper().writerWithDefaultPrettyPrinter().writeValueAsString(this);
diff --git a/engine/src/main/java/org/apache/hop/www/WebServiceServlet.java b/engine/src/main/java/org/apache/hop/www/WebServiceServlet.java
index 4807278f0f..242bfff3f5 100644
--- a/engine/src/main/java/org/apache/hop/www/WebServiceServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/WebServiceServlet.java
@@ -101,8 +101,11 @@ public class WebServiceServlet extends BaseHttpServlet implements IHopServerPlug
 
     String webServiceName = request.getParameter("service");
     if (StringUtils.isEmpty(webServiceName)) {
-      throw new ServletException(
+      sendSafeError(
+          response,
+          HttpServletResponse.SC_BAD_REQUEST,
           "Please specify a service parameter pointing to the name of the web service object");
+      return;
     }
 
     String runConfigurationName = request.getParameter("runConfig");
@@ -282,7 +285,11 @@ public class WebServiceServlet extends BaseHttpServlet implements IHopServerPlug
       pipeline.waitUntilFinished();
 
     } catch (Exception e) {
-      throw new ServletException("Error producing web service output", e);
+      logError("Error producing web service output", e);
+      sendSafeError(
+          response,
+          HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+          "Error producing web service output.");
     }
   }
 
diff --git a/engine/src/main/java/org/apache/hop/www/jaxrs/HopServerResource.java b/engine/src/main/java/org/apache/hop/www/jaxrs/HopServerResource.java
index 3244dfab46..647c81668e 100644
--- a/engine/src/main/java/org/apache/hop/www/jaxrs/HopServerResource.java
+++ b/engine/src/main/java/org/apache/hop/www/jaxrs/HopServerResource.java
@@ -31,6 +31,10 @@ import org.apache.hop.www.HopServerConfig;
 import org.apache.hop.www.HopServerObjectEntry;
 import org.apache.hop.www.HopServerSingleton;
 
+/**
+ * @deprecated Since 2.18.0
+ */
+@Deprecated(since = "2.18.0")
 @Path("/carte")
 public class HopServerResource {
 
diff --git a/engine/src/main/java/org/apache/hop/www/jaxrs/PipelineResource.java b/engine/src/main/java/org/apache/hop/www/jaxrs/PipelineResource.java
index d5a1d0f1ec..dc378da9d8 100644
--- a/engine/src/main/java/org/apache/hop/www/jaxrs/PipelineResource.java
+++ b/engine/src/main/java/org/apache/hop/www/jaxrs/PipelineResource.java
@@ -45,6 +45,10 @@ import org.apache.hop.pipeline.transform.TransformStatus;
 import org.apache.hop.www.HopServerObjectEntry;
 import org.apache.hop.www.HopServerSingleton;
 
+/**
+ * @deprecated Since 2.18.0
+ */
+@Deprecated(since = "2.18.0")
 @Path("/carte/pipeline")
 public class PipelineResource {
 
diff --git a/engine/src/main/java/org/apache/hop/www/jaxrs/WorkflowResource.java b/engine/src/main/java/org/apache/hop/www/jaxrs/WorkflowResource.java
index fd722b5ef9..0cc185b0b2 100644
--- a/engine/src/main/java/org/apache/hop/www/jaxrs/WorkflowResource.java
+++ b/engine/src/main/java/org/apache/hop/www/jaxrs/WorkflowResource.java
@@ -41,6 +41,10 @@ import org.apache.hop.workflow.engine.WorkflowEngineFactory;
 import org.apache.hop.www.HopServerObjectEntry;
 import org.apache.hop.www.HopServerSingleton;
 
+/**
+ * @deprecated Since 2.18.0
+ */
+@Deprecated(since = "2.18.0")
 @Path("/carte/workflow")
 public class WorkflowResource {
 
diff --git a/engine/src/test/java/org/apache/hop/www/BaseHttpServletTest.java b/engine/src/test/java/org/apache/hop/www/BaseHttpServletTest.java
new file mode 100644
index 0000000000..9f33f568d7
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/BaseHttpServletTest.java
@@ -0,0 +1,167 @@
+/*
+ * 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.hop.www;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.ServletOutputStream;
+import jakarta.servlet.WriteListener;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import org.apache.hop.core.logging.ILogChannel;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class BaseHttpServletTest {
+
+  private BaseHttpServlet servlet;
+
+  @BeforeEach
+  void setUp() {
+    servlet = new BaseHttpServlet();
+    servlet.setLog(mock(ILogChannel.class));
+  }
+
+  @Test
+  void supportGraphicEnvironmentGetterSetter() {
+    assertFalse(servlet.isSupportGraphicEnvironment());
+    servlet.setSupportGraphicEnvironment(true);
+    assertTrue(servlet.isSupportGraphicEnvironment());
+  }
+
+  @Test
+  void convertContextPathJettyVsNonJetty() {
+    servlet.setJettyMode(true);
+    assertEquals("/hop/status", servlet.convertContextPath("/hop/status"));
+    servlet.setJettyMode(false);
+    assertEquals("status", servlet.convertContextPath("/hop/status"));
+  }
+
+  @Test
+  void isJsonRequestDetectsParameter() {
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    when(request.getParameter("json")).thenReturn("Y");
+    assertTrue(servlet.isJsonRequest(request));
+    when(request.getParameter("json")).thenReturn("n");
+    assertFalse(servlet.isJsonRequest(request));
+  }
+
+  @Test
+  void sendSafeErrorWhenCommittedOnlySetsStatus() throws IOException {
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    when(response.isCommitted()).thenReturn(true);
+    servlet.sendSafeError(response, HttpServletResponse.SC_BAD_REQUEST, "bad");
+    verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST);
+    verify(response, org.mockito.Mockito.never()).sendError(anyInt(), anyString());
+  }
+
+  @Test
+  void sendSafeErrorFallsBackToSetStatusWhenSendErrorThrows() throws IOException {
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    when(response.isCommitted()).thenReturn(false);
+    doThrow(new IOException("broken")).when(response).sendError(anyInt(), anyString());
+    servlet.sendSafeError(response, HttpServletResponse.SC_CONFLICT, "msg");
+    verify(response).setStatus(HttpServletResponse.SC_CONFLICT);
+  }
+
+  @Test
+  void writeXmlOrJsonApiErrorWritesXmlToOutputStream() throws IOException {
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    when(response.isCommitted()).thenReturn(false);
+    doNothing().when(response).resetBuffer();
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    ServletOutputStream sos =
+        new ServletOutputStream() {
+          @Override
+          public boolean isReady() {
+            return true;
+          }
+
+          @Override
+          public void setWriteListener(WriteListener writeListener) {}
+
+          @Override
+          public void write(int b) throws IOException {
+            baos.write(b);
+          }
+        };
+    when(response.getOutputStream()).thenReturn(sos);
+
+    servlet.writeXmlOrJsonApiError(
+        response, null, true, false, "unit test", new IllegalStateException("cause"));
+
+    verify(response).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+    String body = baos.toString(StandardCharsets.UTF_8);
+    assertTrue(body.contains(WebResult.STRING_ERROR));
+    assertTrue(body.contains("Unable to complete request."));
+  }
+
+  @Test
+  void writeXmlOrJsonApiErrorUsesWriterWhenProvided() throws IOException {
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    when(response.isCommitted()).thenReturn(false);
+    doNothing().when(response).resetBuffer();
+    StringWriter sw = new StringWriter();
+    PrintWriter writer = new PrintWriter(sw);
+
+    servlet.writeXmlOrJsonApiError(
+        response, writer, false, true, "unit test json", new RuntimeException("x"));
+    writer.flush();
+
+    verify(response).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+    assertTrue(sw.toString().contains(WebResult.STRING_ERROR));
+  }
+
+  @Test
+  void getSafeWriterReturnsNullAndSendsErrorWhenGetWriterThrows() throws IOException {
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    when(response.isCommitted()).thenReturn(false);
+    when(response.getWriter()).thenThrow(new IOException("no writer"));
+    doNothing()
+        .when(response)
+        .sendError(eq(HttpServletResponse.SC_INTERNAL_SERVER_ERROR), anyString());
+
+    assertNull(servlet.getSafeWriter(response));
+    verify(response).sendError(eq(HttpServletResponse.SC_INTERNAL_SERVER_ERROR), anyString());
+  }
+
+  @Test
+  void getSafeWriterReturnsWriter() throws IOException {
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    PrintWriter pw = new PrintWriter(new StringWriter());
+    when(response.getWriter()).thenReturn(pw);
+    assertNotNull(servlet.getSafeWriter(response));
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/BodyHttpServletTest.java b/engine/src/test/java/org/apache/hop/www/BodyHttpServletTest.java
new file mode 100644
index 0000000000..23b81262f7
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/BodyHttpServletTest.java
@@ -0,0 +1,146 @@
+/*
+ * 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.hop.www;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.ServletOutputStream;
+import jakarta.servlet.WriteListener;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.variables.IVariables;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class BodyHttpServletTest {
+
+  @BeforeEach
+  void initLog() {
+    HopLogStore.init();
+  }
+
+  static final class OkBodyServlet extends BodyHttpServlet {
+    @Override
+    WebResult generateBody(
+        HttpServletRequest request,
+        HttpServletResponse response,
+        boolean useXML,
+        IVariables variables) {
+      return WebResult.OK;
+    }
+
+    @Override
+    public String getContextPath() {
+      return "/hop/okBody";
+    }
+  }
+
+  static final class FailingBodyServlet extends BodyHttpServlet {
+    @Override
+    WebResult generateBody(
+        HttpServletRequest request,
+        HttpServletResponse response,
+        boolean useXML,
+        IVariables variables) {
+      throw new RuntimeException("boom");
+    }
+
+    @Override
+    public String getContextPath() {
+      return "/hop/failBody";
+    }
+  }
+
+  @Test
+  void doGetReturnsJsonWhenRequested() throws IOException {
+    OkBodyServlet servlet = new OkBodyServlet();
+    servlet.setJettyMode(true);
+    servlet.setLog(org.mockito.Mockito.mock(org.apache.hop.core.logging.ILogChannel.class));
+
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    when(request.getContextPath()).thenReturn("/hop/okBody");
+    when(request.getParameter("xml")).thenReturn(null);
+    when(request.getParameter("json")).thenReturn("Y");
+
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    ServletOutputStream sos =
+        new ServletOutputStream() {
+          @Override
+          public boolean isReady() {
+            return true;
+          }
+
+          @Override
+          public void setWriteListener(WriteListener writeListener) {}
+
+          @Override
+          public void write(int b) throws IOException {
+            baos.write(b);
+          }
+        };
+
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    when(response.getOutputStream()).thenReturn(sos);
+
+    servlet.doGet(request, response);
+
+    String body = baos.toString(StandardCharsets.UTF_8);
+    assertTrue(body.contains(WebResult.STRING_OK));
+  }
+
+  @Test
+  void doGetReturnsErrorJsonWhenGenerateBodyFails() throws IOException {
+    FailingBodyServlet servlet = new FailingBodyServlet();
+    servlet.setJettyMode(true);
+    servlet.setLog(org.mockito.Mockito.mock(org.apache.hop.core.logging.ILogChannel.class));
+
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    when(request.getContextPath()).thenReturn("/hop/failBody");
+    when(request.getParameter("json")).thenReturn("Y");
+
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    ServletOutputStream sos =
+        new ServletOutputStream() {
+          @Override
+          public boolean isReady() {
+            return true;
+          }
+
+          @Override
+          public void setWriteListener(WriteListener writeListener) {}
+
+          @Override
+          public void write(int b) throws IOException {
+            baos.write(b);
+          }
+        };
+
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    when(response.getOutputStream()).thenReturn(sos);
+
+    servlet.doGet(request, response);
+
+    assertTrue(baos.toString(StandardCharsets.UTF_8).contains(WebResult.STRING_ERROR));
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/GetRootServletTest.java b/engine/src/test/java/org/apache/hop/www/DefaultWebServerShutdownHandlerTest.java
similarity index 51%
copy from engine/src/test/java/org/apache/hop/www/GetRootServletTest.java
copy to engine/src/test/java/org/apache/hop/www/DefaultWebServerShutdownHandlerTest.java
index e1ad781c64..0788814d57 100644
--- a/engine/src/test/java/org/apache/hop/www/GetRootServletTest.java
+++ b/engine/src/test/java/org/apache/hop/www/DefaultWebServerShutdownHandlerTest.java
@@ -17,31 +17,12 @@
 
 package org.apache.hop.www;
 
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import jakarta.servlet.ServletException;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-import java.io.IOException;
 import org.junit.jupiter.api.Test;
 
-/**
- * Tests for GetRootServlet class
- *
- * @see GetRootServlet
- */
-class GetRootServletTest {
+class DefaultWebServerShutdownHandlerTest {
 
   @Test
-  void testDoGetReturn404StatusCode() throws ServletException, IOException {
-    GetRootServlet servlet = new GetRootServlet();
-    servlet.setJettyMode(true);
-    HttpServletRequest request =
-        when(mock(HttpServletRequest.class).getRequestURI()).thenReturn("/wrong_path").getMock();
-    HttpServletResponse response = mock(HttpServletResponse.class);
-    servlet.doGet(request, response);
-    verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+  void shutdownWebServerIsNoOp() throws Exception {
+    new DefaultWebServerShutdownHandler().shutdownWebServer();
   }
 }
diff --git a/engine/src/test/java/org/apache/hop/www/GetRootServletTest.java b/engine/src/test/java/org/apache/hop/www/GetExecutionInfoServletTypeTest.java
similarity index 51%
copy from engine/src/test/java/org/apache/hop/www/GetRootServletTest.java
copy to engine/src/test/java/org/apache/hop/www/GetExecutionInfoServletTypeTest.java
index e1ad781c64..21a58dac3e 100644
--- a/engine/src/test/java/org/apache/hop/www/GetRootServletTest.java
+++ b/engine/src/test/java/org/apache/hop/www/GetExecutionInfoServletTypeTest.java
@@ -17,31 +17,18 @@
 
 package org.apache.hop.www;
 
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
 
-import jakarta.servlet.ServletException;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-import java.io.IOException;
 import org.junit.jupiter.api.Test;
 
-/**
- * Tests for GetRootServlet class
- *
- * @see GetRootServlet
- */
-class GetRootServletTest {
+class GetExecutionInfoServletTypeTest {
 
   @Test
-  void testDoGetReturn404StatusCode() throws ServletException, IOException {
-    GetRootServlet servlet = new GetRootServlet();
-    servlet.setJettyMode(true);
-    HttpServletRequest request =
-        when(mock(HttpServletRequest.class).getRequestURI()).thenReturn("/wrong_path").getMock();
-    HttpServletResponse response = mock(HttpServletResponse.class);
-    servlet.doGet(request, response);
-    verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+  void typeEnumValuesAreStable() {
+    GetExecutionInfoServlet.Type[] values = GetExecutionInfoServlet.Type.values();
+    assertEquals(10, values.length);
+    assertSame(GetExecutionInfoServlet.Type.STATE, GetExecutionInfoServlet.Type.valueOf("STATE"));
+    assertSame(GetExecutionInfoServlet.Type.DELETE, GetExecutionInfoServlet.Type.valueOf("DELETE"));
   }
 }
diff --git a/engine/src/test/java/org/apache/hop/www/GetPipelineImageServletTest.java b/engine/src/test/java/org/apache/hop/www/GetPipelineImageServletTest.java
new file mode 100644
index 0000000000..f4efaea99d
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/GetPipelineImageServletTest.java
@@ -0,0 +1,72 @@
+/*
+ * 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.hop.www;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import org.apache.hop.core.logging.HopLogStore;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class GetPipelineImageServletTest {
+
+  @BeforeEach
+  void initLog() {
+    HopLogStore.init();
+  }
+
+  @Test
+  void doGetReturnsEarlyWhenContextPathDoesNotMatchInJettyMode()
+      throws IOException, ServletException {
+    GetPipelineImageServlet servlet = new GetPipelineImageServlet();
+    servlet.setJettyMode(true);
+    servlet.setLog(mock(org.apache.hop.core.logging.ILogChannel.class));
+
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    when(request.getContextPath()).thenReturn("/wrong");
+    HttpServletResponse response = mock(HttpServletResponse.class);
+
+    servlet.doGet(request, response);
+
+    verifyNoInteractions(response);
+  }
+
+  @Test
+  void doGetReturnsNoContentWhenGraphicsDisabled() throws IOException, ServletException {
+    GetPipelineImageServlet servlet = new GetPipelineImageServlet();
+    servlet.setJettyMode(true);
+    servlet.setSupportGraphicEnvironment(false);
+    servlet.setLog(mock(org.apache.hop.core.logging.ILogChannel.class));
+
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    when(request.getContextPath()).thenReturn(GetPipelineImageServlet.CONTEXT_PATH);
+    HttpServletResponse response = mock(HttpServletResponse.class);
+
+    servlet.doGet(request, response);
+
+    verify(response).setStatus(eq(HttpServletResponse.SC_NO_CONTENT));
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/GetRootServletTest.java b/engine/src/test/java/org/apache/hop/www/GetRootServletTest.java
index e1ad781c64..3e9c5c5995 100644
--- a/engine/src/test/java/org/apache/hop/www/GetRootServletTest.java
+++ b/engine/src/test/java/org/apache/hop/www/GetRootServletTest.java
@@ -17,6 +17,8 @@
 
 package org.apache.hop.www;
 
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -25,6 +27,8 @@ import jakarta.servlet.ServletException;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
 import org.junit.jupiter.api.Test;
 
 /**
@@ -34,6 +38,22 @@ import org.junit.jupiter.api.Test;
  */
 class GetRootServletTest {
 
+  @Test
+  void testDoGetWritesHtmlForRootUri() throws ServletException, IOException {
+    GetRootServlet servlet = new GetRootServlet();
+    servlet.setJettyMode(true);
+    StringWriter body = new StringWriter();
+    HttpServletRequest request =
+        when(mock(HttpServletRequest.class).getRequestURI()).thenReturn("/").getMock();
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    when(response.getWriter()).thenReturn(new PrintWriter(body));
+
+    servlet.doGet(request, response);
+
+    verify(response).setStatus(HttpServletResponse.SC_OK);
+    assertTrue(body.toString().contains("<HTML>"));
+  }
+
   @Test
   void testDoGetReturn404StatusCode() throws ServletException, IOException {
     GetRootServlet servlet = new GetRootServlet();
@@ -42,6 +62,6 @@ class GetRootServletTest {
         when(mock(HttpServletRequest.class).getRequestURI()).thenReturn("/wrong_path").getMock();
     HttpServletResponse response = mock(HttpServletResponse.class);
     servlet.doGet(request, response);
-    verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+    verify(response).sendError(eq(HttpServletResponse.SC_NOT_FOUND), eq("Not found."));
   }
 }
diff --git a/engine/src/test/java/org/apache/hop/www/GetWorkflowImageServletTest.java b/engine/src/test/java/org/apache/hop/www/GetWorkflowImageServletTest.java
new file mode 100644
index 0000000000..c4af0d13c0
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/GetWorkflowImageServletTest.java
@@ -0,0 +1,72 @@
+/*
+ * 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.hop.www;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import org.apache.hop.core.logging.HopLogStore;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class GetWorkflowImageServletTest {
+
+  @BeforeEach
+  void initLog() {
+    HopLogStore.init();
+  }
+
+  @Test
+  void doGetReturnsEarlyWhenContextPathDoesNotMatchInJettyMode()
+      throws IOException, ServletException {
+    GetWorkflowImageServlet servlet = new GetWorkflowImageServlet();
+    servlet.setJettyMode(true);
+    servlet.setLog(mock(org.apache.hop.core.logging.ILogChannel.class));
+
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    when(request.getContextPath()).thenReturn("/wrong");
+    HttpServletResponse response = mock(HttpServletResponse.class);
+
+    servlet.doGet(request, response);
+
+    verifyNoInteractions(response);
+  }
+
+  @Test
+  void doGetReturnsNoContentWhenGraphicsDisabled() throws IOException, ServletException {
+    GetWorkflowImageServlet servlet = new GetWorkflowImageServlet();
+    servlet.setJettyMode(true);
+    servlet.setSupportGraphicEnvironment(false);
+    servlet.setLog(mock(org.apache.hop.core.logging.ILogChannel.class));
+
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    when(request.getContextPath()).thenReturn(GetWorkflowImageServlet.CONTEXT_PATH);
+    HttpServletResponse response = mock(HttpServletResponse.class);
+
+    servlet.doGet(request, response);
+
+    verify(response).setStatus(eq(HttpServletResponse.SC_NO_CONTENT));
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/GetRootServletTest.java b/engine/src/test/java/org/apache/hop/www/HopServerCommandExceptionTest.java
similarity index 51%
copy from engine/src/test/java/org/apache/hop/www/GetRootServletTest.java
copy to engine/src/test/java/org/apache/hop/www/HopServerCommandExceptionTest.java
index e1ad781c64..4655de5936 100644
--- a/engine/src/test/java/org/apache/hop/www/GetRootServletTest.java
+++ b/engine/src/test/java/org/apache/hop/www/HopServerCommandExceptionTest.java
@@ -17,31 +17,24 @@
 
 package org.apache.hop.www;
 
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
 
-import jakarta.servlet.ServletException;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-import java.io.IOException;
 import org.junit.jupiter.api.Test;
 
-/**
- * Tests for GetRootServlet class
- *
- * @see GetRootServlet
- */
-class GetRootServletTest {
+class HopServerCommandExceptionTest {
+
+  @Test
+  void messageConstructor() {
+    HopServer.HopServerCommandException ex = new HopServer.HopServerCommandException("m1");
+    assertEquals("m1", ex.getMessage());
+  }
 
   @Test
-  void testDoGetReturn404StatusCode() throws ServletException, IOException {
-    GetRootServlet servlet = new GetRootServlet();
-    servlet.setJettyMode(true);
-    HttpServletRequest request =
-        when(mock(HttpServletRequest.class).getRequestURI()).thenReturn("/wrong_path").getMock();
-    HttpServletResponse response = mock(HttpServletResponse.class);
-    servlet.doGet(request, response);
-    verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+  void messageAndCauseConstructor() {
+    Throwable cause = new IllegalStateException("root");
+    HopServer.HopServerCommandException ex = new HopServer.HopServerCommandException("wrap", cause);
+    assertEquals("wrap", ex.getMessage());
+    assertSame(cause, ex.getCause());
   }
 }
diff --git a/engine/src/test/java/org/apache/hop/www/HopServerObjectEntryTest.java b/engine/src/test/java/org/apache/hop/www/HopServerObjectEntryTest.java
new file mode 100644
index 0000000000..b074264209
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/HopServerObjectEntryTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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.hop.www;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+class HopServerObjectEntryTest {
+
+  @Test
+  void equalsUsesIdOnly() {
+    HopServerObjectEntry a = new HopServerObjectEntry("n1", "id-a");
+    HopServerObjectEntry b = new HopServerObjectEntry("n2", "id-a");
+    HopServerObjectEntry c = new HopServerObjectEntry("n1", "id-b");
+    assertEquals(a, b);
+    assertEquals(a, a);
+    assertNotEquals(a, c);
+    assertFalse(a.equals("x"));
+  }
+
+  @Test
+  void hashCodeUsesId() {
+    assertEquals(
+        new HopServerObjectEntry("a", "same").hashCode(),
+        new HopServerObjectEntry("b", "same").hashCode());
+  }
+
+  @Test
+  void compareOrdersByNameThenId() {
+    HopServerObjectEntry x = new HopServerObjectEntry("b", "2");
+    HopServerObjectEntry y = new HopServerObjectEntry("a", "9");
+    assertTrue(x.compareTo(y) > 0);
+    HopServerObjectEntry z = new HopServerObjectEntry("b", "1");
+    assertTrue(x.compareTo(z) > 0);
+  }
+
+  @Test
+  void defaultConstructorAndSetters() {
+    HopServerObjectEntry e = new HopServerObjectEntry();
+    e.setName("nm");
+    e.setId("i1");
+    assertEquals("nm", e.getName());
+    assertEquals("i1", e.getId());
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/HopServerPluginServletSmokeTest.java b/engine/src/test/java/org/apache/hop/www/HopServerPluginServletSmokeTest.java
new file mode 100644
index 0000000000..ecdad5d8a7
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/HopServerPluginServletSmokeTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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.hop.www;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Light coverage for servlet identity methods (context path, service description) on plugins that
+ * are expensive to exercise end-to-end.
+ */
+class HopServerPluginServletSmokeTest {
+
+  private static void assertPluginPaths(IHopServerPlugin servlet, String expectedContextPath) {
+    assertEquals(expectedContextPath, servlet.getContextPath());
+    assertTrue(servlet.getService().startsWith(expectedContextPath));
+  }
+
+  @Test
+  void registerWorkflowServletPaths() {
+    assertPluginPaths(new RegisterWorkflowServlet(), RegisterWorkflowServlet.CONTEXT_PATH);
+  }
+
+  @Test
+  void deleteExecutionInfoServletPaths() {
+    assertPluginPaths(new DeleteExecutionInfoServlet(), DeleteExecutionInfoServlet.CONTEXT_PATH);
+  }
+
+  @Test
+  void execPipelineServletPaths() {
+    assertPluginPaths(new ExecPipelineServlet(), ExecPipelineServlet.CONTEXT_PATH);
+  }
+
+  @Test
+  void execWorkflowServletPaths() {
+    assertPluginPaths(new ExecWorkflowServlet(), ExecWorkflowServlet.CONTEXT_PATH);
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/HopServerServletTest.java b/engine/src/test/java/org/apache/hop/www/HopServerServletTest.java
new file mode 100644
index 0000000000..076e6e3d21
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/HopServerServletTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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.hop.www;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.lang.reflect.Field;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import org.apache.hop.core.logging.HopLogStore;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class HopServerServletTest {
+
+  private HopServerServlet servlet;
+  private Map<String, IHopServerPlugin> registry;
+
+  @BeforeEach
+  void setUp() throws Exception {
+    HopLogStore.init();
+    servlet = new HopServerServlet();
+    registry = new ConcurrentHashMap<>();
+    Field f = HopServerServlet.class.getDeclaredField("hopServerPluginRegistry");
+    f.setAccessible(true);
+    f.set(servlet, registry);
+  }
+
+  @Test
+  void doGetDispatchesToRegisteredPlugin() throws Exception {
+    IHopServerPlugin plugin = mock(IHopServerPlugin.class);
+    registry.put("/myPlugin", plugin);
+
+    HttpServletRequest req = mock(HttpServletRequest.class);
+    when(req.getPathInfo()).thenReturn("/myPlugin");
+    HttpServletResponse resp = mock(HttpServletResponse.class);
+
+    servlet.doGet(req, resp);
+
+    verify(plugin).doGet(req, resp);
+  }
+
+  @Test
+  void doGetSendsNotFoundWhenPluginMissing() throws Exception {
+    HttpServletRequest req = mock(HttpServletRequest.class);
+    when(req.getPathInfo()).thenReturn("/unknown");
+    HttpServletResponse resp = mock(HttpServletResponse.class);
+    when(resp.isCommitted()).thenReturn(false);
+
+    servlet.doGet(req, resp);
+
+    verify(resp).sendError(eq(HttpServletResponse.SC_NOT_FOUND), eq("Not found."));
+  }
+
+  @Test
+  void doGetTrimsTrailingSlashOnPath() throws Exception {
+    IHopServerPlugin plugin = mock(IHopServerPlugin.class);
+    registry.put("/trim", plugin);
+
+    HttpServletRequest req = mock(HttpServletRequest.class);
+    when(req.getPathInfo()).thenReturn("/trim/");
+    HttpServletResponse resp = mock(HttpServletResponse.class);
+
+    servlet.doGet(req, resp);
+
+    verify(plugin).doGet(req, resp);
+  }
+
+  @Test
+  void doGetHandlesPluginServletException() throws Exception {
+    IHopServerPlugin plugin = mock(IHopServerPlugin.class);
+    doThrow(new ServletException("plugin failed")).when(plugin).doGet(any(), any());
+    registry.put("/bad", plugin);
+
+    HttpServletRequest req = mock(HttpServletRequest.class);
+    when(req.getPathInfo()).thenReturn("/bad");
+    HttpServletResponse resp = mock(HttpServletResponse.class);
+    when(resp.isCommitted()).thenReturn(false);
+
+    servlet.doGet(req, resp);
+
+    verify(resp)
+        .sendError(eq(HttpServletResponse.SC_INTERNAL_SERVER_ERROR), eq("Plugin request failed."));
+  }
+
+  @Test
+  void doPostCatchesFailureWhenRegistryUninitialized() throws Exception {
+    HopServerServlet bare = new HopServerServlet();
+    HttpServletRequest req = mock(HttpServletRequest.class);
+    when(req.getPathInfo()).thenReturn("/any");
+    HttpServletResponse resp = mock(HttpServletResponse.class);
+    when(resp.isCommitted()).thenReturn(false);
+
+    bare.doPost(req, resp);
+
+    verify(resp)
+        .sendError(
+            eq(HttpServletResponse.SC_INTERNAL_SERVER_ERROR),
+            eq("Unable to process server request."));
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/HopServerSingletonTest.java b/engine/src/test/java/org/apache/hop/www/HopServerSingletonTest.java
new file mode 100644
index 0000000000..36fd9a8f48
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/HopServerSingletonTest.java
@@ -0,0 +1,58 @@
+/*
+ * 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.hop.www;
+
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.apache.hop.core.Const;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@ExtendWith(RestoreHopEngineEnvironmentExtension.class)
+class HopServerSingletonTest {
+
+  @Test
+  void installPurgeTimerDoesNothingWhenTimeoutIsZeroViaEnvironment() {
+    HopServerConfig config = mock(HopServerConfig.class);
+    when(config.getObjectTimeoutMinutes()).thenReturn(0);
+    System.setProperty(Const.HOP_SERVER_OBJECT_TIMEOUT_MINUTES, "0");
+
+    ILogChannel log = mock(ILogChannel.class);
+    HopServerSingleton.installPurgeTimer(config, log, new PipelineMap(), new WorkflowMap());
+
+    verify(log, never()).logBasic(contains("Installing timer"));
+  }
+
+  @Test
+  void installPurgeTimerLogsWhenTimeoutPositive() {
+    HopServerConfig config = mock(HopServerConfig.class);
+    when(config.getObjectTimeoutMinutes()).thenReturn(15);
+    System.clearProperty(Const.HOP_SERVER_OBJECT_TIMEOUT_MINUTES);
+
+    ILogChannel log = mock(ILogChannel.class);
+    HopServerSingleton.installPurgeTimer(config, log, new PipelineMap(), new WorkflowMap());
+
+    verify(log).logBasic(contains("Installing timer"));
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/HopServerStatusTest.java b/engine/src/test/java/org/apache/hop/www/HopServerStatusTest.java
new file mode 100644
index 0000000000..081c0e63b0
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/HopServerStatusTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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.hop.www;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.core.exception.HopException;
+import org.junit.jupiter.api.Test;
+
+class HopServerStatusTest {
+
+  @Test
+  void defaultConstructorInitializesLists() {
+    HopServerStatus status = new HopServerStatus();
+    assertNotNull(status.getPipelineStatusList());
+    assertNotNull(status.getWorkflowStatusList());
+    assertTrue(status.getPipelineStatusList().isEmpty());
+  }
+
+  @Test
+  void statusDescriptionConstructor() {
+    HopServerStatus status = new HopServerStatus("Online");
+    assertEquals("Online", status.getStatusDescription());
+  }
+
+  @Test
+  void getXmlWithEmptyLists() throws HopException {
+    HopServerStatus status = new HopServerStatus("Up");
+    status.setMemoryFree(1L);
+    status.setMemoryTotal(2L);
+    status.setCpuCores(4);
+    String xml = status.getXml();
+    assertTrue(xml.contains(HopServerStatus.XML_TAG));
+    assertTrue(xml.contains("Up"));
+    assertTrue(xml.contains("pipeline_status_list"));
+    assertTrue(xml.contains("workflow_status_list"));
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/GetRootServletTest.java b/engine/src/test/java/org/apache/hop/www/IHopServerServletTest.java
similarity index 51%
copy from engine/src/test/java/org/apache/hop/www/GetRootServletTest.java
copy to engine/src/test/java/org/apache/hop/www/IHopServerServletTest.java
index e1ad781c64..b3f41a8043 100644
--- a/engine/src/test/java/org/apache/hop/www/GetRootServletTest.java
+++ b/engine/src/test/java/org/apache/hop/www/IHopServerServletTest.java
@@ -17,31 +17,15 @@
 
 package org.apache.hop.www;
 
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 
-import jakarta.servlet.ServletException;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-import java.io.IOException;
 import org.junit.jupiter.api.Test;
 
-/**
- * Tests for GetRootServlet class
- *
- * @see GetRootServlet
- */
-class GetRootServletTest {
+class IHopServerServletTest {
 
   @Test
-  void testDoGetReturn404StatusCode() throws ServletException, IOException {
-    GetRootServlet servlet = new GetRootServlet();
-    servlet.setJettyMode(true);
-    HttpServletRequest request =
-        when(mock(HttpServletRequest.class).getRequestURI()).thenReturn("/wrong_path").getMock();
-    HttpServletResponse response = mock(HttpServletResponse.class);
-    servlet.doGet(request, response);
-    verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+  void getServiceImplementedByLambda() {
+    IHopServerServlet servlet = () -> "svc";
+    assertEquals("svc", servlet.getService());
   }
 }
diff --git a/engine/src/test/java/org/apache/hop/www/PipelineMapTest.java b/engine/src/test/java/org/apache/hop/www/PipelineMapTest.java
new file mode 100644
index 0000000000..e115562f46
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/PipelineMapTest.java
@@ -0,0 +1,82 @@
+/*
+ * 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.hop.www;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import org.apache.hop.pipeline.PipelineConfiguration;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.engine.IPipelineEngine;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class PipelineMapTest {
+
+  private PipelineMap map;
+
+  @BeforeEach
+  void setUp() {
+    map = new PipelineMap();
+  }
+
+  @Test
+  void addGetRemoveAndList() {
+    IPipelineEngine<PipelineMeta> pipeline = mock(IPipelineEngine.class);
+    PipelineMeta meta = mock(PipelineMeta.class);
+    when(pipeline.getPipelineMeta()).thenReturn(meta);
+    when(meta.getName()).thenReturn("P1");
+    PipelineConfiguration configuration = mock(PipelineConfiguration.class);
+
+    map.addPipeline("P1", "cid-1", pipeline, configuration);
+
+    HopServerObjectEntry entry = new HopServerObjectEntry("P1", "cid-1");
+    assertSame(pipeline, map.getPipeline(entry));
+    assertSame(pipeline, map.getPipeline("P1"));
+    assertSame(configuration, map.getConfiguration(entry));
+    assertSame(configuration, map.getConfiguration("P1"));
+
+    List<HopServerObjectEntry> objects = map.getPipelineObjects();
+    assertEquals(1, objects.size());
+    assertEquals(entry.getId(), objects.get(0).getId());
+
+    assertNotNull(map.getFirstServerObjectEntry("P1"));
+
+    map.removePipeline(entry);
+    assertNull(map.getPipeline(entry));
+    assertTrue(map.getPipelineObjects().isEmpty());
+  }
+
+  @Test
+  void getConfigurationReturnsNullForUnknownEntry() {
+    assertNull(map.getConfiguration(new HopServerObjectEntry("x", "y")));
+  }
+
+  @Test
+  void hopServerConfigRoundTrip() {
+    HopServerConfig cfg = mock(HopServerConfig.class);
+    map.setHopServerConfig(cfg);
+    assertSame(cfg, map.getHopServerConfig());
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/SocketPortAllocationTest.java b/engine/src/test/java/org/apache/hop/www/SocketPortAllocationTest.java
new file mode 100644
index 0000000000..8ce3c571e9
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/SocketPortAllocationTest.java
@@ -0,0 +1,62 @@
+/*
+ * 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.hop.www;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Date;
+import org.junit.jupiter.api.Test;
+
+class SocketPortAllocationTest {
+
+  @Test
+  void constructorAndAccessors() {
+    Date now = new Date();
+    SocketPortAllocation a =
+        new SocketPortAllocation(
+            8080, now, "cluster1", "pipe", "srcSrv", "srcTr", "0", "tgtSrv", "tgtTr", "1");
+    assertEquals(8080, a.getPort());
+    assertEquals(now, a.getLastRequested());
+    assertEquals("cluster1", a.getClusterRunId());
+    assertEquals("pipe", a.getPipelineName());
+    assertTrue(a.isAllocated());
+    a.setAllocated(false);
+    assertFalse(a.isAllocated());
+    a.setPort(9090);
+    assertEquals(9090, a.getPort());
+    a.setPipelineName("p2");
+    assertEquals("p2", a.getPipelineName());
+  }
+
+  @Test
+  void equalsAndHashCodeUsePort() {
+    Date d = new Date();
+    SocketPortAllocation x =
+        new SocketPortAllocation(1, d, "c", "p", "s", "t", "0", "s2", "t2", "1");
+    SocketPortAllocation y =
+        new SocketPortAllocation(1, d, "other", "p2", "s", "t", "0", "s2", "t2", "1");
+    assertEquals(x, y);
+    SocketPortAllocation z =
+        new SocketPortAllocation(2, d, "c", "p", "s", "t", "0", "s2", "t2", "1");
+    assertNotEquals(x, z);
+    assertEquals(Integer.valueOf(1).hashCode(), x.hashCode());
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/SslConfigurationTest.java b/engine/src/test/java/org/apache/hop/www/SslConfigurationTest.java
new file mode 100644
index 0000000000..4b0adc6695
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/SslConfigurationTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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.hop.www;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+class SslConfigurationTest {
+
+  @Test
+  void defaultConstructorSetsKeyStoreType() {
+    SslConfiguration ssl = new SslConfiguration();
+    assertNotNull(ssl.getKeyStoreType());
+  }
+
+  @Test
+  void setKeyStoreTypeIgnoresNull() {
+    SslConfiguration ssl = new SslConfiguration();
+    String before = ssl.getKeyStoreType();
+    ssl.setKeyStoreType(null);
+    assertEquals(before, ssl.getKeyStoreType());
+    ssl.setKeyStoreType("PKCS12");
+    assertEquals("PKCS12", ssl.getKeyStoreType());
+  }
+
+  @Test
+  void setKeyStoreRequiresNonEmpty() {
+    SslConfiguration ssl = new SslConfiguration();
+    assertThrows(IllegalArgumentException.class, () -> ssl.setKeyStore(null));
+    assertThrows(IllegalArgumentException.class, () -> ssl.setKeyStore(""));
+    ssl.setKeyStore("/path/ks.p12");
+    assertEquals("/path/ks.p12", ssl.getKeyStore());
+  }
+
+  @Test
+  void setKeyStorePasswordRequiresNonEmpty() {
+    SslConfiguration ssl = new SslConfiguration();
+    ssl.setKeyStore("/ks");
+    assertThrows(IllegalArgumentException.class, () -> ssl.setKeyStorePassword(null));
+    assertThrows(IllegalArgumentException.class, () -> ssl.setKeyStorePassword(""));
+    ssl.setKeyStorePassword("secret");
+    assertEquals("secret", ssl.getKeyStorePassword());
+  }
+
+  @Test
+  void getKeyPasswordFallsBackToKeyStorePassword() {
+    SslConfiguration ssl = new SslConfiguration();
+    ssl.setKeyStore("/ks");
+    ssl.setKeyStorePassword("storePass");
+    assertEquals("storePass", ssl.getKeyPassword());
+    ssl.setKeyPassword("keyPass");
+    assertEquals("keyPass", ssl.getKeyPassword());
+  }
+
+  @Test
+  void getXmlIncludesTagWhenValuesSet() {
+    SslConfiguration ssl = new SslConfiguration();
+    ssl.setKeyStore("/tmp/test.jks");
+    ssl.setKeyStorePassword("p1");
+    String xml = ssl.getXml();
+    assertTrue(xml.contains(SslConfiguration.XML_TAG));
+    assertTrue(xml.contains("/tmp/test.jks"));
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/GetRootServletTest.java b/engine/src/test/java/org/apache/hop/www/StatusServletUtilsTest.java
similarity index 51%
copy from engine/src/test/java/org/apache/hop/www/GetRootServletTest.java
copy to engine/src/test/java/org/apache/hop/www/StatusServletUtilsTest.java
index e1ad781c64..8b0ca7e36f 100644
--- a/engine/src/test/java/org/apache/hop/www/GetRootServletTest.java
+++ b/engine/src/test/java/org/apache/hop/www/StatusServletUtilsTest.java
@@ -17,31 +17,16 @@
 
 package org.apache.hop.www;
 
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 
-import jakarta.servlet.ServletException;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-import java.io.IOException;
 import org.junit.jupiter.api.Test;
 
-/**
- * Tests for GetRootServlet class
- *
- * @see GetRootServlet
- */
-class GetRootServletTest {
+class StatusServletUtilsTest {
 
   @Test
-  void testDoGetReturn404StatusCode() throws ServletException, IOException {
-    GetRootServlet servlet = new GetRootServlet();
-    servlet.setJettyMode(true);
-    HttpServletRequest request =
-        when(mock(HttpServletRequest.class).getRequestURI()).thenReturn("/wrong_path").getMock();
-    HttpServletResponse response = mock(HttpServletResponse.class);
-    servlet.doGet(request, response);
-    verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+  void constantsAreStable() {
+    assertEquals("/content/common-ui/resources/themes", StatusServletUtils.RESOURCES_PATH);
+    assertEquals("/static", StatusServletUtils.STATIC_PATH);
+    assertEquals("/hop", StatusServletUtils.HOP_ROOT);
   }
 }
diff --git a/engine/src/test/java/org/apache/hop/www/WebResultTest.java b/engine/src/test/java/org/apache/hop/www/WebResultTest.java
index 641afabb60..206efe2a48 100644
--- a/engine/src/test/java/org/apache/hop/www/WebResultTest.java
+++ b/engine/src/test/java/org/apache/hop/www/WebResultTest.java
@@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNotSame;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.util.UUID;
 import org.apache.hop.core.exception.HopXmlException;
@@ -75,6 +76,16 @@ class WebResultTest {
     assertEquals(original.getId(), copy.getId());
   }
 
+  @Test
+  void testGetJsonSerializesBeanFields() {
+    WebResult result = new WebResult(WebResult.STRING_ERROR, "msg", "id1");
+    String json = result.getJson();
+    assertTrue(json.contains("\"result\""));
+    assertTrue(json.contains(WebResult.STRING_ERROR));
+    assertTrue(json.contains("msg"));
+    assertTrue(json.contains("id1"));
+  }
+
   @Test
   void testSetters() {
     WebResult result = new WebResult("");
diff --git a/engine/src/test/java/org/apache/hop/www/WebServerShutdownHookTest.java b/engine/src/test/java/org/apache/hop/www/WebServerShutdownHookTest.java
new file mode 100644
index 0000000000..2768128e2f
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/WebServerShutdownHookTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.hop.www;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import org.junit.jupiter.api.Test;
+
+class WebServerShutdownHookTest {
+
+  @Test
+  void runInvokesStopServerWhenNotShuttingDown() throws Exception {
+    WebServer webServer = mock(WebServer.class);
+    WebServerShutdownHook hook = new WebServerShutdownHook(webServer);
+    hook.run();
+    verify(webServer).stopServer();
+  }
+
+  @Test
+  void runSwallowsStopServerException() throws Exception {
+    WebServer webServer = mock(WebServer.class);
+    doThrow(new RuntimeException("stop failed")).when(webServer).stopServer();
+    WebServerShutdownHook hook = new WebServerShutdownHook(webServer);
+    hook.run();
+    verify(webServer).stopServer();
+  }
+
+  @Test
+  void runSkipsStopServerWhenAlreadyShuttingDown() throws Exception {
+    WebServer webServer = mock(WebServer.class);
+    WebServerShutdownHook hook = new WebServerShutdownHook(webServer);
+    hook.setShuttingDown(true);
+    hook.run();
+    verify(webServer, never()).stopServer();
+  }
+
+  @Test
+  void shuttingDownFlag() {
+    WebServerShutdownHook hook = new WebServerShutdownHook(mock(WebServer.class));
+    hook.setShuttingDown(true);
+    assertTrue(hook.isShuttingDown());
+    hook.setShuttingDown(false);
+    assertFalse(hook.isShuttingDown());
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/WorkflowMapTest.java b/engine/src/test/java/org/apache/hop/www/WorkflowMapTest.java
new file mode 100644
index 0000000000..d4259fda87
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/WorkflowMapTest.java
@@ -0,0 +1,102 @@
+/*
+ * 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.hop.www;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import org.apache.hop.workflow.WorkflowConfiguration;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.engine.IWorkflowEngine;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class WorkflowMapTest {
+
+  private WorkflowMap map;
+
+  @BeforeEach
+  void setUp() {
+    map = new WorkflowMap();
+  }
+
+  @Test
+  void addWorkflowValidatesNameAndId() {
+    IWorkflowEngine<WorkflowMeta> wf = mock(IWorkflowEngine.class);
+    WorkflowConfiguration cfg = mock(WorkflowConfiguration.class);
+    assertThrows(RuntimeException.class, () -> map.addWorkflow("", "id", wf, cfg));
+    assertThrows(RuntimeException.class, () -> map.addWorkflow("n", null, wf, cfg));
+  }
+
+  @Test
+  void addGetRemoveFindAndList() {
+    IWorkflowEngine<WorkflowMeta> wf = mock(IWorkflowEngine.class);
+    when(wf.getWorkflowName()).thenReturn("W1");
+    when(wf.getContainerId()).thenReturn("cid-w");
+    WorkflowConfiguration cfg = mock(WorkflowConfiguration.class);
+
+    map.addWorkflow("W1", "cid-w", wf, cfg);
+
+    HopServerObjectEntry entry = new HopServerObjectEntry("W1", "cid-w");
+    assertSame(wf, map.getWorkflow(entry));
+    assertSame(wf, map.getWorkflow("W1"));
+    assertSame(cfg, map.getConfiguration(entry));
+    assertSame(wf, map.findWorkflow("cid-w"));
+    assertSame(wf, map.findWorkflow("W1", "cid-w"));
+    assertSame(wf, map.findWorkflow("W1", null));
+
+    assertNotNull(map.getFirstHopServerObjectEntry("W1"));
+
+    List<HopServerObjectEntry> keys = map.getWorkflowObjects();
+    assertEquals(1, keys.size());
+
+    map.removeWorkflow(entry);
+    assertNull(map.getWorkflow(entry));
+    assertTrue(map.getWorkflowObjects().isEmpty());
+  }
+
+  @Test
+  void replaceWorkflowReplacesExisting() {
+    IWorkflowEngine<WorkflowMeta> oldWf = mock(IWorkflowEngine.class);
+    when(oldWf.getWorkflowName()).thenReturn("W2");
+    when(oldWf.getContainerId()).thenReturn("id2");
+    WorkflowConfiguration cfg = mock(WorkflowConfiguration.class);
+    map.addWorkflow("W2", "id2", oldWf, cfg);
+
+    IWorkflowEngine<WorkflowMeta> newWf = mock(IWorkflowEngine.class);
+    when(newWf.getWorkflowName()).thenReturn("W2");
+    when(newWf.getContainerId()).thenReturn("id2");
+
+    map.replaceWorkflow(oldWf, newWf, cfg);
+    assertSame(newWf, map.getWorkflow("W2"));
+  }
+
+  @Test
+  void hopServerConfigRoundTrip() {
+    HopServerConfig cfg = mock(HopServerConfig.class);
+    map.setHopServerConfig(cfg);
+    assertSame(cfg, map.getHopServerConfig());
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/jaxrs/NVPairTest.java b/engine/src/test/java/org/apache/hop/www/jaxrs/NVPairTest.java
new file mode 100644
index 0000000000..64e575263c
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/jaxrs/NVPairTest.java
@@ -0,0 +1,44 @@
+/*
+ * 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.hop.www.jaxrs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.Test;
+
+class NVPairTest {
+
+  @Test
+  void defaultConstructor() {
+    NVPair p = new NVPair();
+    assertNull(p.getName());
+    assertNull(p.getValue());
+  }
+
+  @Test
+  void valueConstructorAndSetters() {
+    NVPair p = new NVPair("k", "v");
+    assertEquals("k", p.getName());
+    assertEquals("v", p.getValue());
+    p.setName("k2");
+    p.setValue("v2");
+    assertEquals("k2", p.getName());
+    assertEquals("v2", p.getValue());
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/jaxrs/PipelineStatusTest.java b/engine/src/test/java/org/apache/hop/www/jaxrs/PipelineStatusTest.java
new file mode 100644
index 0000000000..96bfb6dac3
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/jaxrs/PipelineStatusTest.java
@@ -0,0 +1,48 @@
+/*
+ * 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.hop.www.jaxrs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Collections;
+import org.apache.hop.pipeline.transform.TransformStatus;
+import org.junit.jupiter.api.Test;
+
+class PipelineStatusTest {
+
+  @Test
+  void accessorsAndAddTransformStatus() {
+    PipelineStatus ps = new PipelineStatus();
+    ps.setId("id1");
+    ps.setName("pipe");
+    ps.setStatus("Running");
+    assertEquals("id1", ps.getId());
+    assertEquals("pipe", ps.getName());
+    assertEquals("Running", ps.getStatus());
+
+    TransformStatus ts = new TransformStatus();
+    ps.addTransformStatus(ts);
+    assertEquals(1, ps.getTransformStatuses().size());
+    assertSame(ts, ps.getTransformStatuses().get(0));
+
+    ps.setTransformStatuses(Collections.emptyList());
+    assertTrue(ps.getTransformStatuses().isEmpty());
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/jaxrs/ServerStatusTest.java b/engine/src/test/java/org/apache/hop/www/jaxrs/ServerStatusTest.java
new file mode 100644
index 0000000000..c45d1bdd1d
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/jaxrs/ServerStatusTest.java
@@ -0,0 +1,41 @@
+/*
+ * 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.hop.www.jaxrs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+class ServerStatusTest {
+
+  @Test
+  void defaultConstructorPopulatesRuntimeMetrics() {
+    ServerStatus status = new ServerStatus();
+    assertTrue(status.getMemoryTotal() > 0);
+    assertNotNull(status.getOsName());
+    assertTrue(status.getCpuCores() > 0);
+  }
+
+  @Test
+  void statusDescriptionConstructor() {
+    ServerStatus status = new ServerStatus("Online");
+    assertEquals("Online", status.getStatusDescription());
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/jaxrs/WorkflowStatusTest.java b/engine/src/test/java/org/apache/hop/www/jaxrs/WorkflowStatusTest.java
new file mode 100644
index 0000000000..e0b4690c38
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/jaxrs/WorkflowStatusTest.java
@@ -0,0 +1,36 @@
+/*
+ * 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.hop.www.jaxrs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+class WorkflowStatusTest {
+
+  @Test
+  void accessors() {
+    WorkflowStatus ws = new WorkflowStatus();
+    ws.setId("w1");
+    ws.setName("wf");
+    ws.setStatus("Finished");
+    assertEquals("w1", ws.getId());
+    assertEquals("wf", ws.getName());
+    assertEquals("Finished", ws.getStatus());
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/service/WebServiceTest.java b/engine/src/test/java/org/apache/hop/www/service/WebServiceTest.java
new file mode 100644
index 0000000000..f84b8c043a
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/service/WebServiceTest.java
@@ -0,0 +1,66 @@
+/*
+ * 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.hop.www.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+class WebServiceTest {
+
+  @Test
+  void defaultConstructorAndSetters() {
+    WebService ws = new WebService();
+    ws.setName("svc1");
+    ws.setEnabled(true);
+    ws.setFilename("/path/pipeline.hpl");
+    ws.setTransformName("t1");
+    ws.setFieldName("f1");
+    ws.setContentType("application/json");
+    ws.setStatusCode("200");
+    ws.setListingStatus(true);
+    ws.setBodyContentVariable("bodyVar");
+    ws.setRunConfigurationName("local");
+    ws.setHeaderContentVariable("hdrVar");
+
+    assertEquals("svc1", ws.getName());
+    assertTrue(ws.isEnabled());
+    assertEquals("/path/pipeline.hpl", ws.getFilename());
+    assertEquals("t1", ws.getTransformName());
+    assertEquals("f1", ws.getFieldName());
+    assertEquals("application/json", ws.getContentType());
+    assertEquals("200", ws.getStatusCode());
+    assertTrue(ws.isListingStatus());
+    assertEquals("bodyVar", ws.getBodyContentVariable());
+    assertEquals("local", ws.getRunConfigurationName());
+    assertEquals("hdrVar", ws.getHeaderContentVariable());
+  }
+
+  @Test
+  void fullConstructor() {
+    WebService ws =
+        new WebService(
+            "n", false, "file.hpl", "tr", "fld", "text/plain", "201", false, "b", "run", "h");
+    assertEquals("n", ws.getName());
+    assertFalse(ws.isEnabled());
+    assertEquals("file.hpl", ws.getFilename());
+    assertEquals("h", ws.getHeaderContentVariable());
+  }
+}
diff --git a/plugins/misc/async/src/main/java/org/apache/hop/www/async/AsyncStatusServlet.java b/plugins/misc/async/src/main/java/org/apache/hop/www/async/AsyncStatusServlet.java
index bc36f2cf62..25451e4726 100644
--- a/plugins/misc/async/src/main/java/org/apache/hop/www/async/AsyncStatusServlet.java
+++ b/plugins/misc/async/src/main/java/org/apache/hop/www/async/AsyncStatusServlet.java
@@ -91,14 +91,20 @@ public class AsyncStatusServlet extends BaseHttpServlet implements IHopServerPlu
 
     String webServiceName = request.getParameter("service");
     if (StringUtils.isEmpty(webServiceName)) {
-      throw new ServletException(
+      sendSafeError(
+          response,
+          HttpServletResponse.SC_BAD_REQUEST,
           "Please specify a service parameter pointing to the name of the asynchronous webservice object");
+      return;
     }
 
     String serverObjectId = request.getParameter("id");
     if (StringUtils.isEmpty(serverObjectId)) {
-      throw new ServletException(
+      sendSafeError(
+          response,
+          HttpServletResponse.SC_BAD_REQUEST,
           "Please specify an id parameter pointing to the unique ID of the asynchronous webservice object");
+      return;
     }
 
     try {
@@ -161,7 +167,11 @@ public class AsyncStatusServlet extends BaseHttpServlet implements IHopServerPlu
       response.setStatus(HttpServletResponse.SC_OK);
 
     } catch (Exception e) {
-      throw new ServletException("Error getting asynchronous web service status", e);
+      logError("Error getting asynchronous web service status", e);
+      sendSafeError(
+          response,
+          HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+          "Error getting asynchronous web service status.");
     }
   }