diff --git a/pom.xml b/pom.xml
index 22e0585..fdef533 100644
--- a/pom.xml
+++ b/pom.xml
@@ -112,12 +112,6 @@
junit-jupiter-engine
test
-
- org.mockito
- mockito-inline
- 5.2.0
- test
-
@@ -213,13 +207,16 @@
maven-pmd-plugin
- 3.26.0
+ 3.28.0
verify
@@ -262,13 +259,15 @@
com.github.spotbugs
spotbugs-maven-plugin
- 4.9.3.0
+ 4.10.3.0
verify
@@ -355,9 +354,18 @@
3.5.6
-
+
+
+ com.github.spotbugs
+ spotbugs-maven-plugin
+ 4.10.3.0
+
+
+
maven-pmd-plugin
+ 3.28.0
true
${project.build.sourceEncoding}
diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java
index 4fb44f8..698c31f 100644
--- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java
+++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java
@@ -1,16 +1,34 @@
package org.metricshub.winrm.cli;
+/*-
+ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲
+ * WinRM Java Client
+ * ჻჻჻჻჻჻
+ * Copyright 2023 - 2026 MetricsHub
+ * ჻჻჻჻჻჻
+ * Licensed 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.
+ * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱
+ */
+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.ArgumentMatchers.isNull;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.mockStatic;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
+import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueCommandExchange;
+import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueEnumeration;
+import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueShellCreation;
+import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueShellDeletion;
+import static org.metricshub.winrm.light.FakeWsmanResponses.instance;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
@@ -22,10 +40,11 @@
import java.util.Map;
import java.util.concurrent.TimeoutException;
import org.junit.jupiter.api.Test;
-import org.mockito.MockedStatic;
+import org.metricshub.winrm.WinRMHttpProtocolEnum;
import org.metricshub.winrm.WindowsRemoteCommandResult;
-import org.metricshub.winrm.WindowsRemoteProcessUtils;
+import org.metricshub.winrm.light.FakeWsmanServer;
import org.metricshub.winrm.light.LightWinRMService;
+import org.metricshub.winrm.service.WinRMEndpoint;
class WinRmCliTest {
@@ -238,21 +257,44 @@ void honorsAnAmbientInsecureTlsProperty() throws Exception {
@Test
void decodesCommandOutputUsingTheRemoteWindowsCodePage() throws Exception {
- final LightWinRMService service = mock(LightWinRMService.class);
final Charset windowsCharset = Charset.forName("windows-1251");
- final WindowsRemoteCommandResult expected = new WindowsRemoteCommandResult("Результат", "", 0.0f, 0);
- final long timeout = 1_234L;
- when(service.executeCommand("whoami", null, windowsCharset, timeout)).thenReturn(expected);
-
- try (MockedStatic processUtils = mockStatic(WindowsRemoteProcessUtils.class)) {
- processUtils
- .when(() -> WindowsRemoteProcessUtils.getWindowsEncodingCharset(service, timeout))
- .thenReturn(windowsCharset);
- final WinRmCli.LightRemoteOperations remote = new WinRmCli.LightRemoteOperations(service);
-
- assertSame(expected, remote.executeCommand("whoami", timeout));
- processUtils.verify(() -> WindowsRemoteProcessUtils.getWindowsEncodingCharset(service, timeout));
- verify(service).executeCommand(eq("whoami"), isNull(), eq(windowsCharset), eq(timeout));
+ final long timeout = 30_000L;
+
+ // End to end against the in-process WSMan server: the remote reports Windows code page
+ // 1251 and the command output arrives in that encoding — the CLI must query the code page
+ // and decode the stream bytes with it, or the Cyrillic output turns into mojibake.
+ try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) {
+ enqueueEnumeration(server, instance("Win32_OperatingSystem", "CodeSet", "1251"));
+ enqueueShellCreation(server);
+ enqueueCommandExchange(server, "Результат".getBytes(windowsCharset), new byte[0], 0);
+ enqueueShellDeletion(server);
+
+ final WinRMEndpoint endpoint = new WinRMEndpoint(
+ WinRMHttpProtocolEnum.HTTP,
+ "127.0.0.1",
+ server.port(),
+ "FAKE\\user",
+ "secret".toCharArray(),
+ null
+ );
+ final WindowsRemoteCommandResult result;
+ try (
+ WinRmCli.LightRemoteOperations remote = new WinRmCli.LightRemoteOperations(
+ LightWinRMService.createInstance(endpoint, timeout, null, null)
+ )) {
+ result = remote.executeCommand("whoami", timeout);
+ }
+
+ assertEquals("Результат", result.getStdout());
+ assertEquals(0, result.getStatusCode());
+
+ // The decoding charset really came from the remote code-page query
+ assertTrue(
+ server
+ .decryptedRequests()
+ .stream()
+ .anyMatch(request -> request.contains("SELECT CodeSet FROM Win32_OperatingSystem"))
+ );
}
}
diff --git a/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java b/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java
index 20645e5..2852806 100644
--- a/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java
+++ b/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java
@@ -1,5 +1,25 @@
package org.metricshub.winrm.command;
+/*-
+ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲
+ * WinRM Java Client
+ * ჻჻჻჻჻჻
+ * Copyright 2023 - 2026 MetricsHub
+ * ჻჻჻჻჻჻
+ * Licensed 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.
+ * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱
+ */
+
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
@@ -8,36 +28,47 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.metricshub.winrm.WinRMHttpProtocolEnum.HTTPS;
import static org.metricshub.winrm.command.WinRMCommandExecutor.execute;
+import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueCommandExchange;
+import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueEnumeration;
+import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueShellCreation;
+import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueShellDeletion;
+import static org.metricshub.winrm.light.FakeWsmanResponses.instance;
import static org.metricshub.winrm.service.client.auth.AuthenticationEnum.NTLM;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.ArgumentMatchers.isNull;
-import static org.mockito.Mockito.mockStatic;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Base64;
import java.util.List;
-import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
-import org.metricshub.winrm.ScriptedWindowsRemoteExecutor;
import org.metricshub.winrm.WindowsRemoteCommandResult;
-import org.metricshub.winrm.service.WinRMEndpoint;
-import org.metricshub.winrm.service.WinRMExecutorFactory;
+import org.metricshub.winrm.light.FakeWsmanServer;
import org.metricshub.winrm.service.client.auth.AuthenticationEnum;
-import org.mockito.MockedStatic;
+/**
+ * End-to-end tests of {@link WinRMCommandExecutor} against {@link FakeWsmanServer}: the whole
+ * stack — factory, {@code LightWinRMService}, real NTLM handshake and message encryption, shell
+ * lifecycle, and (for the file-copy path) the in-shell file transfer — runs for real; only the
+ * scripted SOAP response bodies are canned.
+ */
class WinRMCommandExecutorTest {
private static final String COMMAND = "launch";
private static final String HOSTNAME = "host";
- private static final String USERNAME = "domain\\user";
- private static final char[] PASSWORD = "pass".toCharArray();
+ private static final String USERNAME = "FAKE\\user";
+ private static final String USER = "user";
+ private static final String DOMAIN = "FAKE";
+ private static final String PASSWORD_STRING = "pass";
+ private static final char[] PASSWORD = PASSWORD_STRING.toCharArray();
private static final long TIMEOUT = 30 * 1000L;
private static final Path TICKET_CACHE = Paths.get("path");
private static final List AUTHENTICATIONS = singletonList(NTLM);
+ private static final String LOCALHOST = "127.0.0.1";
+
+ private static final byte[] NO_OUTPUT = new byte[0];
@TempDir
Path tempDir;
@@ -152,35 +183,45 @@ void testExecuteArgumentChecks() {
@Test
void testExecuteWithoutFilesToCopy() throws Exception {
- final WindowsRemoteCommandResult expected = new WindowsRemoteCommandResult("stdout", "stderr", 1.0f, 0);
+ try (FakeWsmanServer server = new FakeWsmanServer(DOMAIN, USER, PASSWORD_STRING)) {
+ // Three executions: a null file list, an empty one, and a list of blank names must all
+ // take the same no-copy path. Each execution creates (and closes) its own executor.
+ final List> fileListVariants = new ArrayList<>();
+ fileListVariants.add(null);
+ fileListVariants.add(emptyList());
+ fileListVariants.add(singletonList(" \r\t\n "));
- final ScriptedWindowsRemoteExecutor executor = new ScriptedWindowsRemoteExecutor()
- .expectWql("CodeSet", List.of(Map.of("CodeSet", "65001")))
- .expectCommand(COMMAND, expected);
+ for (final List localFileToCopyList : fileListVariants) {
+ enqueueCodeSetQuery(server, "65001");
+ enqueueShellCreation(server);
+ enqueueCommandExchange(server, "stdout".getBytes(UTF_8), "stderr".getBytes(UTF_8), 0);
+ enqueueShellDeletion(server);
- try (final MockedStatic mockedFactory = mockStatic(WinRMExecutorFactory.class)) {
- mockedFactory
- .when(() -> WinRMExecutorFactory.createInstance(any(WinRMEndpoint.class), anyLong(), isNull(), isNull()))
- .thenReturn(executor);
-
- assertEquals(
- expected,
- execute(COMMAND, null, HOSTNAME, null, USERNAME, PASSWORD, null, TIMEOUT, null, null, null)
- );
+ final WindowsRemoteCommandResult result = execute(
+ COMMAND,
+ null,
+ LOCALHOST,
+ server.port(),
+ USERNAME,
+ PASSWORD,
+ null,
+ TIMEOUT,
+ localFileToCopyList,
+ null,
+ null
+ );
- assertEquals(
- expected,
- execute(COMMAND, null, HOSTNAME, null, USERNAME, PASSWORD, null, TIMEOUT, emptyList(), null, null)
- );
-
- // A list of blank names behaves like no list at all
- assertEquals(
- expected,
- execute(COMMAND, null, HOSTNAME, null, USERNAME, PASSWORD, null, TIMEOUT, singletonList(" \r\t\n "), null, null)
- );
+ assertEquals("stdout", result.getStdout());
+ assertEquals("stderr", result.getStderr());
+ assertEquals(0, result.getStatusCode());
+ }
- assertEquals(List.of(COMMAND, COMMAND, COMMAND), executor.getExecutedCommands());
- assertTrue(executor.isClosed());
+ final List requests = server.decryptedRequests();
+ // Each execution queried the remote code page, ran the command verbatim (no CMD.EXE /C
+ // wrapper on the no-copy path), and deleted its shell on close
+ assertEquals(3, count(requests, "SELECT CodeSet FROM Win32_OperatingSystem"));
+ assertEquals(3, count(requests, "" + COMMAND + ""));
+ assertEquals(3, count(requests, "http://schemas.xmlsoap.org/ws/2004/09/transfer/Delete"));
}
}
@@ -190,41 +231,34 @@ void testExecuteWithFileToCopy() throws Exception {
final Path localFile = tempDir.resolve("MyScript.vbs");
Files.write(localFile, content);
- final WindowsRemoteCommandResult expected = new WindowsRemoteCommandResult("stdout", "stderr", 1.0f, 0);
+ final String hashOutput = "SHA256 hash of file:\r\n" +
+ sha256Hex(content) +
+ "\r\nCertUtil: -hashfile command completed successfully.\r\n";
- final StringBuilder hex = new StringBuilder();
- for (final byte b : MessageDigest.getInstance("SHA-256").digest(content)) {
- hex.append(String.format("%02x", b));
- }
- final WindowsRemoteCommandResult remoteHash = new WindowsRemoteCommandResult(
- "SHA256 hash of file:\r\n" + hex + "\r\nCertUtil: -hashfile command completed successfully.\r\n",
- "",
- 0.1f,
- 0
- );
- final WindowsRemoteCommandResult failure = new WindowsRemoteCommandResult("", "not found", 0.1f, 1);
- final WindowsRemoteCommandResult success = new WindowsRemoteCommandResult("", "", 0.1f, 0);
-
- final ScriptedWindowsRemoteExecutor executor = new ScriptedWindowsRemoteExecutor()
- .expectWql("WindowsDirectory", List.of(Map.of("WindowsDirectory", "C:\\Windows")))
- .expectWql("CodeSet", List.of(Map.of("CodeSet", "65001")))
- .expectCommand("MKDIR", success)
- .expectCommand(" echo ", success)
- .expectCommand("certutil -f -decode", remoteHash)
- .expectCommand("MOVE /Y", remoteHash)
- .expectCommand("certutil -hashfile", failure)
- .expectCommand("CSCRIPT", expected);
-
- try (final MockedStatic mockedFactory = mockStatic(WinRMExecutorFactory.class)) {
- mockedFactory
- .when(() -> WinRMExecutorFactory.createInstance(any(WinRMEndpoint.class), anyLong(), isNull(), isNull()))
- .thenReturn(executor);
-
- final WindowsRemoteCommandResult actual = execute(
+ try (FakeWsmanServer server = new FakeWsmanServer(DOMAIN, USER, PASSWORD_STRING)) {
+ // The in-shell transfer sequence of ShellFileCopy, scripted response by response
+ enqueueEnumeration(server, instance("Win32_OperatingSystem", "WindowsDirectory", "C:\\Windows"));
+ enqueueShellCreation(server);
+ // 1: purge + MKDIR of the remote temporary directory
+ enqueueCommandExchange(server, NO_OUTPUT, NO_OUTPUT, 0);
+ // 2: pre-transfer digest probe — the destination does not exist yet
+ enqueueCommandExchange(server, NO_OUTPUT, "CertUtil: -hashfile command FAILED: 0x80070002".getBytes(UTF_8), 1);
+ // 3: the single chunked-echo upload leg (the payload is small)
+ enqueueCommandExchange(server, NO_OUTPUT, NO_OUTPUT, 0);
+ // 4: certutil -decode + digest probe of the staging file
+ enqueueCommandExchange(server, hashOutput.getBytes(UTF_8), NO_OUTPUT, 0);
+ // 5: publish (MOVE) + digest probe of the destination
+ enqueueCommandExchange(server, hashOutput.getBytes(UTF_8), NO_OUTPUT, 0);
+ // then the code-page query and the actual command
+ enqueueCodeSetQuery(server, "65001");
+ enqueueCommandExchange(server, "stdout".getBytes(UTF_8), "stderr".getBytes(UTF_8), 0);
+ enqueueShellDeletion(server);
+
+ final WindowsRemoteCommandResult result = execute(
"CSCRIPT " + localFile,
null,
- HOSTNAME,
- null,
+ LOCALHOST,
+ server.port(),
USERNAME,
PASSWORD,
null,
@@ -234,15 +268,44 @@ void testExecuteWithFileToCopy() throws Exception {
null
);
- assertEquals(expected, actual);
+ assertEquals("stdout", result.getStdout());
+ assertEquals("stderr", result.getStderr());
+ assertEquals(0, result.getStatusCode());
+
+ final List requests = server.decryptedRequests();
+
+ // The file content went over the wire base64-encoded in an echo leg
+ final String base64Content = Base64.getEncoder().encodeToString(content);
+ assertTrue(requests.stream().anyMatch(request -> request.contains(base64Content)));
// The executed command references the remote copy, wrapped in CMD.EXE /C (...)
- final String finalCommand = executor.getExecutedCommands().get(executor.getExecutedCommands().size() - 1);
- assertTrue(finalCommand.startsWith("CMD.EXE /C (CSCRIPT "));
- assertTrue(finalCommand.contains("\\Temp\\"));
+ final String finalCommand = requests
+ .stream()
+ .filter(request -> request.contains("CMD.EXE /C (CSCRIPT "))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("No CMD.EXE /C command found:\n" + String.join("\n---\n", requests)));
+ assertTrue(finalCommand.contains("\\Temp\\"), finalCommand);
// The remote name is content-addressed: MyScript..vbs
- assertTrue(finalCommand.matches("(?s).*MyScript\\.[0-9a-f]{12}\\.vbs.*"));
- assertTrue(executor.isClosed());
+ assertTrue(finalCommand.matches("(?s).*MyScript\\.[0-9a-f]{12}\\.vbs.*"), finalCommand);
+
+ // close() deleted the shell
+ assertEquals(1, count(requests, "http://schemas.xmlsoap.org/ws/2004/09/transfer/Delete"));
+ }
+ }
+
+ private static void enqueueCodeSetQuery(final FakeWsmanServer server, final String codeSet) {
+ enqueueEnumeration(server, instance("Win32_OperatingSystem", "CodeSet", codeSet));
+ }
+
+ private static long count(final List requests, final String needle) {
+ return requests.stream().filter(request -> request.contains(needle)).count();
+ }
+
+ private static String sha256Hex(final byte[] content) throws Exception {
+ final StringBuilder hex = new StringBuilder();
+ for (final byte b : MessageDigest.getInstance("SHA-256").digest(content)) {
+ hex.append(String.format("%02x", b));
}
+ return hex.toString();
}
}
diff --git a/src/test/java/org/metricshub/winrm/light/FakeWsmanResponses.java b/src/test/java/org/metricshub/winrm/light/FakeWsmanResponses.java
new file mode 100644
index 0000000..af9da42
--- /dev/null
+++ b/src/test/java/org/metricshub/winrm/light/FakeWsmanResponses.java
@@ -0,0 +1,300 @@
+package org.metricshub.winrm.light;
+
+/*-
+ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲
+ * WinRM Java Client
+ * ჻჻჻჻჻჻
+ * Copyright 2023 - 2026 MetricsHub
+ * ჻჻჻჻჻჻
+ * Licensed 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.
+ * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱
+ */
+
+import java.util.Base64;
+
+/**
+ * Canned WSMan SOAP response bodies — and helpers that enqueue whole protocol exchanges — for
+ * end-to-end tests against {@link FakeWsmanServer}: WQL enumeration results, the command shell
+ * lifecycle, and WSMan faults. Shared by the protocol tests in this package and the executor and
+ * CLI tests that exercise the full stack from the public API down to the wire.
+ */
+public final class FakeWsmanResponses {
+
+ /** Shell resource id used by the scripted shell-lifecycle responses. */
+ public static final String SHELL_ID = "SHELL-1";
+
+ /** Command id used by the scripted command exchanges. */
+ public static final String COMMAND_ID = "CMD-1";
+
+ private static final String SOAP_NS = "http://www.w3.org/2003/05/soap-envelope";
+ private static final String WSEN = "http://schemas.xmlsoap.org/ws/2004/09/enumeration";
+ private static final String WSMAN = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd";
+ private static final String RSP = "http://schemas.microsoft.com/wbem/wsman/1/windows/shell";
+ private static final String FAULT_NS = "http://schemas.microsoft.com/wbem/wsman/1/wsmanfault";
+ private static final String WMI_NS_PREFIX = "http://schemas.microsoft.com/wbem/wsman/1/wmi/root/cimv2/";
+
+ private FakeWsmanResponses() {}
+
+ // --- SOAP body builders --------------------------------------------------
+
+ /**
+ * Wrap a body in a SOAP 1.2 envelope.
+ *
+ * @param body the body content
+ * @return the complete envelope
+ */
+ public static String envelope(final String body) {
+ return "" + body + "";
+ }
+
+ /**
+ * One WMI instance as WinRM serializes it in an enumeration, e.g.
+ * {@code instance("Win32_Service", "Name", "Spooler", "State", "Running")}.
+ *
+ * @param className the WMI class name
+ * @param properties alternating property names and values
+ * @return the instance element
+ */
+ public static String instance(final String className, final String... properties) {
+ final StringBuilder xml = new StringBuilder();
+ xml.append("");
+ for (int i = 0; i + 1 < properties.length; i += 2) {
+ xml
+ .append("')
+ .append(properties[i + 1])
+ .append("');
+ }
+ return xml.append("').toString();
+ }
+
+ /**
+ * A complete single-page WQL result: an optimized EnumerateResponse carrying the items and the
+ * end-of-sequence marker, so no Pull follows.
+ *
+ * @param instances the serialized WMI instances (see {@link #instance(String, String...)})
+ * @return the EnumerateResponse body
+ */
+ public static String enumerationDone(final String... instances) {
+ final StringBuilder xml = new StringBuilder();
+ xml
+ .append("");
+ for (final String item : instances) {
+ xml.append(item);
+ }
+ return xml.append("").toString();
+ }
+
+ /**
+ * The ResourceCreated body answering a shell Create request.
+ *
+ * @param shellId the shell id the response designates
+ * @return the ResourceCreated body
+ */
+ public static String resourceCreated(final String shellId) {
+ return ("" +
+ "http://127.0.0.1/wsman" +
+ "" +
+ "" +
+ RSP +
+ "/cmd" +
+ "" +
+ shellId +
+ "" +
+ "");
+ }
+
+ /**
+ * The CommandResponse body answering a Command request.
+ *
+ * @param commandId the command id the response designates
+ * @return the CommandResponse body
+ */
+ public static String commandResponse(final String commandId) {
+ return ("" +
+ commandId +
+ "");
+ }
+
+ /**
+ * A ReceiveResponse body carrying the given streams and, optionally, the final command state.
+ *
+ * @param streams the concatenated Stream elements (see {@link #stream(String, String, byte[])})
+ * @param commandState the CommandState element (see {@link #done(String, int)}), or null while
+ * the command is still running
+ * @return the ReceiveResponse body
+ */
+ public static String receiveResponse(final String streams, final String commandState) {
+ return ("" +
+ streams +
+ (commandState == null ? "" : commandState) +
+ "");
+ }
+
+ /**
+ * One base64-encoded output Stream element of a ReceiveResponse.
+ *
+ * @param name the stream name (stdout or stderr)
+ * @param commandId the command the stream belongs to
+ * @param content the raw stream bytes
+ * @return the Stream element
+ */
+ public static String stream(final String name, final String commandId, final byte[] content) {
+ return ("" +
+ Base64.getEncoder().encodeToString(content) +
+ "");
+ }
+
+ /**
+ * The CommandState element reporting command completion.
+ *
+ * @param commandId the finished command
+ * @param exitCode the command exit code
+ * @return the CommandState element
+ */
+ public static String done(final String commandId, final int exitCode) {
+ return ("" +
+ exitCode +
+ "");
+ }
+
+ /**
+ * The SignalResponse body answering the terminate Signal.
+ *
+ * @return the SignalResponse body
+ */
+ public static String signalResponse() {
+ return "";
+ }
+
+ /**
+ * A complete WSMan fault envelope (not to be wrapped in {@link #envelope(String)}).
+ *
+ * @param code the WSManFault code
+ * @param reason the fault reason text
+ * @return the fault envelope
+ */
+ public static String fault(final String code, final String reason) {
+ return fault(code, reason, null);
+ }
+
+ /**
+ * A complete WSMan fault envelope (not to be wrapped in {@link #envelope(String)}) with a
+ * provider-level detail message.
+ *
+ * @param code the WSManFault code
+ * @param reason the fault reason text
+ * @param detailMessage the provider-level detail, or null to repeat the reason
+ * @return the fault envelope
+ */
+ public static String fault(final String code, final String reason, final String detailMessage) {
+ return ("" +
+ "s:Receiver" +
+ "" +
+ reason +
+ "" +
+ "" +
+ "" +
+ (detailMessage == null ? reason : detailMessage) +
+ "" +
+ "");
+ }
+
+ // --- whole-exchange enqueue helpers ---------------------------------------
+
+ /**
+ * Enqueue a complete single-page WQL result: the Enumerate request is answered with the items
+ * and the end-of-sequence marker, so the client issues no Pull.
+ *
+ * @param server the fake server to script
+ * @param instances the serialized WMI instances (see {@link #instance(String, String...)})
+ */
+ public static void enqueueEnumeration(final FakeWsmanServer server, final String... instances) {
+ server.enqueue(200, envelope(enumerationDone(instances)));
+ }
+
+ /**
+ * Enqueue the shell-creation response that answers the Create request preceding the first
+ * command executed on a fresh executor.
+ *
+ * @param server the fake server to script
+ */
+ public static void enqueueShellCreation(final FakeWsmanServer server) {
+ server.enqueue(200, envelope(resourceCreated(SHELL_ID)));
+ }
+
+ /**
+ * Enqueue a complete command exchange: the CommandResponse, one ReceiveResponse carrying the
+ * whole output and the final state, and the SignalResponse to the terminate Signal.
+ *
+ * @param server the fake server to script
+ * @param stdout the raw stdout bytes (as the remote code page encodes them)
+ * @param stderr the raw stderr bytes
+ * @param exitCode the command exit code
+ */
+ public static void enqueueCommandExchange(
+ final FakeWsmanServer server,
+ final byte[] stdout,
+ final byte[] stderr,
+ final int exitCode
+ ) {
+ final StringBuilder streams = new StringBuilder();
+ if (stdout.length > 0) {
+ streams.append(stream("stdout", COMMAND_ID, stdout));
+ }
+ if (stderr.length > 0) {
+ streams.append(stream("stderr", COMMAND_ID, stderr));
+ }
+ server
+ .enqueue(200, envelope(commandResponse(COMMAND_ID)))
+ .enqueue(200, envelope(receiveResponse(streams.toString(), done(COMMAND_ID, exitCode))))
+ .enqueue(200, envelope(signalResponse()));
+ }
+
+ /**
+ * Enqueue the response to the shell Delete that {@code close()} sends once a shell was created.
+ *
+ * @param server the fake server to script
+ */
+ public static void enqueueShellDeletion(final FakeWsmanServer server) {
+ server.enqueue(200, envelope(""));
+ }
+}
diff --git a/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java b/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java
index 16fb878..1274719 100644
--- a/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java
+++ b/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java
@@ -1,5 +1,25 @@
package org.metricshub.winrm.light;
+/*-
+ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲
+ * WinRM Java Client
+ * ჻჻჻჻჻჻
+ * Copyright 2023 - 2026 MetricsHub
+ * ჻჻჻჻჻჻
+ * Licensed 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.
+ * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱
+ */
+
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@@ -31,7 +51,7 @@
* The NTLMv2 verification is real: a client that derives a wrong hash (e.g. a domain-case
* regression) fails authentication here just like against a real host.
*/
-final class FakeWsmanServer implements AutoCloseable {
+public final class FakeWsmanServer implements AutoCloseable {
/** One scripted HTTP response: status code and the plaintext SOAP body to encrypt and serve. */
static final class Scripted {
@@ -83,7 +103,15 @@ static final class Scripted {
private volatile boolean closed;
private volatile boolean chunkedResponses;
- FakeWsmanServer(final String domain, final String user, final String password) throws IOException {
+ /**
+ * Start the fake server on an ephemeral local port.
+ *
+ * @param domain the NetBIOS domain the client is expected to authenticate with
+ * @param user the user name the client is expected to authenticate with
+ * @param password the password the client's NTLMv2 proof is verified against
+ * @throws IOException when the listening socket cannot be opened
+ */
+ public FakeWsmanServer(final String domain, final String user, final String password) throws IOException {
this.expectedDomain = domain.toUpperCase(Locale.ROOT);
this.expectedUser = user;
this.expectedPassword = password;
@@ -93,12 +121,21 @@ static final class Scripted {
this.acceptThread.start();
}
- int port() {
+ /**
+ * @return the local port the server listens on
+ */
+ public int port() {
return serverSocket.getLocalPort();
}
- /** Queue the next scripted response (served in order, one per decrypted request). */
- FakeWsmanServer enqueue(final int status, final String soapBody) {
+ /**
+ * Queue the next scripted response (served in order, one per decrypted request).
+ *
+ * @param status the HTTP status code to respond with
+ * @param soapBody the plaintext SOAP body to encrypt and serve
+ * @return this server, for chaining
+ */
+ public FakeWsmanServer enqueue(final int status, final String soapBody) {
synchronized (script) {
script.addLast(new Scripted(status, soapBody));
}
@@ -110,14 +147,20 @@ FakeWsmanServer enqueue(final int status, final String soapBody) {
* extension, and trailer fields after the terminating chunk — instead of {@code Content-Length},
* like a real WinRM host does. A client that mis-reads the framing (e.g. leaves the trailers in
* the socket) desyncs the kept-alive connection and fails on the NEXT request.
+ *
+ * @return this server, for chaining
*/
- FakeWsmanServer withChunkedResponses() {
+ public FakeWsmanServer withChunkedResponses() {
chunkedResponses = true;
return this;
}
- /** The plaintext SOAP request bodies received so far, in order (after decryption). */
- List decryptedRequests() {
+ /**
+ * The plaintext SOAP request bodies received so far, in order (after decryption).
+ *
+ * @return a copy of the decrypted request bodies
+ */
+ public List decryptedRequests() {
return new ArrayList<>(decryptedRequests);
}
diff --git a/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java b/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java
index 555edf2..f7c62fe 100644
--- a/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java
+++ b/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java
@@ -1,11 +1,39 @@
package org.metricshub.winrm.light;
+/*-
+ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲
+ * WinRM Java Client
+ * ჻჻჻჻჻჻
+ * Copyright 2023 - 2026 MetricsHub
+ * ჻჻჻჻჻჻
+ * Licensed 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.
+ * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱
+ */
+
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.metricshub.winrm.light.FakeWsmanResponses.commandResponse;
+import static org.metricshub.winrm.light.FakeWsmanResponses.done;
+import static org.metricshub.winrm.light.FakeWsmanResponses.envelope;
+import static org.metricshub.winrm.light.FakeWsmanResponses.fault;
+import static org.metricshub.winrm.light.FakeWsmanResponses.instance;
+import static org.metricshub.winrm.light.FakeWsmanResponses.receiveResponse;
+import static org.metricshub.winrm.light.FakeWsmanResponses.resourceCreated;
+import static org.metricshub.winrm.light.FakeWsmanResponses.signalResponse;
+import static org.metricshub.winrm.light.FakeWsmanResponses.stream;
import java.nio.charset.StandardCharsets;
-import java.util.Base64;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
@@ -29,11 +57,9 @@ class WsmanProtocolTest {
private static final String PASSWORD = "s3cret-Passw0rd";
private static final long TIMEOUT = 30_000L;
- private static final String SOAP_NS = "http://www.w3.org/2003/05/soap-envelope";
private static final String WSEN = "http://schemas.xmlsoap.org/ws/2004/09/enumeration";
private static final String WSMAN = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd";
private static final String RSP = "http://schemas.microsoft.com/wbem/wsman/1/windows/shell";
- private static final String FAULT_NS = "http://schemas.microsoft.com/wbem/wsman/1/wsmanfault";
private FakeWsmanServer server;
@@ -197,18 +223,17 @@ void commandLifecycleReassemblesMultibyteOutputSplitAcrossReceives() throws Exce
server
.enqueue(200, envelope(resourceCreated("SHELL-1")))
.enqueue(200, envelope(commandResponse("CMD-1")))
- .enqueue(200, envelope(receiveResponse("CMD-1", stream("stdout", chunk1), null)))
+ .enqueue(200, envelope(receiveResponse(stream("stdout", "CMD-1", chunk1), null)))
.enqueue(
200,
envelope(
receiveResponse(
- "CMD-1",
- stream("stdout", chunk2) + stream("stderr", "warn!".getBytes(StandardCharsets.UTF_8)),
+ stream("stdout", "CMD-1", chunk2) + stream("stderr", "CMD-1", "warn!".getBytes(StandardCharsets.UTF_8)),
done("CMD-1", 7)
)
)
)
- .enqueue(200, envelope(""));
+ .enqueue(200, envelope(signalResponse()));
try (LightWinRMService service = client(PASSWORD)) {
final WindowsRemoteCommandResult result = service.executeCommand(
@@ -255,9 +280,11 @@ void receiveRetriesOnOperationTimeoutFault() throws Exception {
)
.enqueue(
200,
- envelope(receiveResponse("CMD-1", stream("stdout", "late".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0)))
+ envelope(
+ receiveResponse(stream("stdout", "CMD-1", "late".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0))
+ )
)
- .enqueue(200, envelope(""));
+ .enqueue(200, envelope(signalResponse()));
try (LightWinRMService service = client(PASSWORD)) {
final WindowsRemoteCommandResult result = service.executeCommand("slow", null, StandardCharsets.UTF_8, TIMEOUT);
@@ -282,15 +309,18 @@ void commandExitCodeAboveIntegerMaxIsNarrowedNotRejected() throws Exception {
200,
envelope(
receiveResponse(
- "CMD-1",
- stream("stdout", "CertUtil: -hashfile command FAILED: 0x80070002".getBytes(StandardCharsets.UTF_8)),
+ stream(
+ "stdout",
+ "CMD-1",
+ "CertUtil: -hashfile command FAILED: 0x80070002".getBytes(StandardCharsets.UTF_8)
+ ),
"2147942402"
)
)
)
- .enqueue(200, envelope(""));
+ .enqueue(200, envelope(signalResponse()));
try (LightWinRMService service = client(PASSWORD)) {
final WindowsRemoteCommandResult result = service.executeCommand(
@@ -314,7 +344,7 @@ void terminateSignalToleratesShellNotFoundFault() throws Exception {
.enqueue(200, envelope(commandResponse("CMD-1")))
.enqueue(
200,
- envelope(receiveResponse("CMD-1", stream("stdout", "ok".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0)))
+ envelope(receiveResponse(stream("stdout", "CMD-1", "ok".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0)))
)
.enqueue(
500,
@@ -372,90 +402,7 @@ void wrongPasswordSurfacesTheCxfAuthenticationErrorMessage() throws Exception {
// --- response body builders -----------------------------------------------------
- private static String envelope(final String body) {
- return "" + body + "";
- }
-
private static String service(final String name, final String state) {
- return ("" +
- "" +
- name +
- "" +
- state +
- "");
- }
-
- private static String resourceCreated(final String shellId) {
- return ("" +
- "http://127.0.0.1/wsman" +
- "" +
- "" +
- RSP +
- "/cmd" +
- "" +
- shellId +
- "" +
- "");
- }
-
- private static String commandResponse(final String commandId) {
- return ("" +
- commandId +
- "");
- }
-
- private static String receiveResponse(final String commandId, final String streams, final String commandState) {
- return ("" +
- streams +
- (commandState == null ? "" : commandState) +
- "");
- }
-
- private static String stream(final String name, final byte[] content) {
- return ("" +
- Base64.getEncoder().encodeToString(content) +
- "");
- }
-
- private static String done(final String commandId, final int exitCode) {
- return ("" +
- exitCode +
- "");
- }
-
- private static String fault(final String code, final String reason) {
- return fault(code, reason, null);
- }
-
- private static String fault(final String code, final String reason, final String detailMessage) {
- return ("" +
- "s:Receiver" +
- "" +
- reason +
- "" +
- "" +
- "" +
- (detailMessage == null ? reason : detailMessage) +
- "" +
- "");
+ return instance("Win32_Service", "Name", name, "State", state);
}
}
diff --git a/src/test/java/org/metricshub/winrm/wql/WinRMWqlExecutorTest.java b/src/test/java/org/metricshub/winrm/wql/WinRMWqlExecutorTest.java
index ce8393f..6a71f69 100644
--- a/src/test/java/org/metricshub/winrm/wql/WinRMWqlExecutorTest.java
+++ b/src/test/java/org/metricshub/winrm/wql/WinRMWqlExecutorTest.java
@@ -1,32 +1,43 @@
package org.metricshub.winrm.wql;
+/*-
+ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲
+ * WinRM Java Client
+ * ჻჻჻჻჻჻
+ * Copyright 2023 - 2026 MetricsHub
+ * ჻჻჻჻჻჻
+ * Licensed 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.
+ * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱
+ */
+
import static java.nio.file.Paths.get;
import static java.util.Arrays.asList;
import static java.util.Collections.singletonList;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.metricshub.winrm.WinRMHttpProtocolEnum.HTTPS;
+import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueEnumeration;
+import static org.metricshub.winrm.light.FakeWsmanResponses.instance;
import static org.metricshub.winrm.service.client.auth.AuthenticationEnum.NTLM;
import static org.metricshub.winrm.wql.WinRMWqlExecutor.executeWql;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.ArgumentMatchers.isNull;
-import static org.mockito.Mockito.doReturn;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.mockStatic;
import java.nio.file.Path;
import java.util.ArrayList;
-import java.util.HashMap;
import java.util.List;
-import java.util.Map;
import org.junit.jupiter.api.Test;
-import org.metricshub.winrm.WindowsRemoteExecutor;
-import org.metricshub.winrm.service.WinRMEndpoint;
-import org.metricshub.winrm.service.WinRMExecutorFactory;
+import org.metricshub.winrm.light.FakeWsmanServer;
import org.metricshub.winrm.service.client.auth.AuthenticationEnum;
-import org.mockito.MockedStatic;
class WinRMWqlExecutorTest {
@@ -53,7 +64,7 @@ void resultCollectionsAreDefensivelyCopiedAndUnmodifiable() {
}
@Test
- void testExecute() throws Exception {
+ void testExecuteArgumentChecks() {
final String wqlQuery = "Select Name,Path from Win32_Share";
final String hostname = "host";
final String username = "user";
@@ -62,7 +73,6 @@ void testExecute() throws Exception {
final Path ticketCache = get("path");
final List authentications = singletonList(NTLM);
- // check arguments
assertThrows(
IllegalArgumentException.class,
() -> executeWql(HTTPS, null, 5986, username, password, null, wqlQuery, timeout, ticketCache, authentications)
@@ -92,51 +102,46 @@ void testExecute() throws Exception {
IllegalArgumentException.class,
() -> executeWql(HTTPS, hostname, 5986, username, password, null, wqlQuery, 0L, ticketCache, authentications)
);
+ }
+
+ @Test
+ void executesTheQueryThroughTheRealProtocolStack() throws Exception {
+ final String wqlQuery = "Select Name,Path from Win32_Share";
- try (final MockedStatic mockedFactory = mockStatic(WinRMExecutorFactory.class)) {
- final WindowsRemoteExecutor executor = mock(WindowsRemoteExecutor.class);
-
- final List