From 264537a93862e4a2bd5be085cf9c12ef011d5eb3 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Mon, 27 Jul 2026 13:25:33 +0200 Subject: [PATCH] Remove Mockito: replace mock-based tests with end-to-end tests against FakeWsmanServer The last three Mockito test files were forced into mockStatic by static factory methods. They now run the real stack instead: WinRMWqlExecutor and WinRMCommandExecutor go through WinRMExecutorFactory -> LightWinRMService -> WsmanClient -> FakeWsmanServer (genuine NTLMv2 handshake and message encryption), and the CLI code-page test scripts the CodeSet query on the fake server rather than mocking the charset lookup. FakeWsmanServer is now public and the canned SOAP response bodies are shared in the new FakeWsmanResponses helper, also used by WsmanProtocolTest. mockito-inline 5.2.0 (deprecated, incompatible with recent JDKs) leaves the dependency tree along with byte-buddy and objenesis. maven-pmd-plugin (3.28.0, PMD 7.17.0) and spotbugs-maven-plugin (4.10.3.0) are upgraded because the previous versions crashed on the JDK 25 class library ("Unsupported class file major version 69"); gate and site report stay on the same versions. mvn clean verify site is green on JDK 17 and JDK 25: the JDK pin for local builds is gone. Closes #119 Co-Authored-By: Claude Fable 5 --- pom.xml | 36 ++- .../metricshub/winrm/cli/WinRmCliTest.java | 88 +++-- .../command/WinRMCommandExecutorTest.java | 211 +++++++----- .../winrm/light/FakeWsmanResponses.java | 300 ++++++++++++++++++ .../winrm/light/FakeWsmanServer.java | 59 +++- .../winrm/light/WsmanProtocolTest.java | 141 +++----- .../winrm/wql/WinRMWqlExecutorTest.java | 99 +++--- 7 files changed, 671 insertions(+), 263 deletions(-) create mode 100644 src/test/java/org/metricshub/winrm/light/FakeWsmanResponses.java 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> result = new ArrayList<>(); - { - final Map row = new HashMap<>(); - row.put("Name", "C$"); - row.put("Path", "C:\\"); - result.add(row); - } - { - final Map row = new HashMap<>(); - row.put("Name", "SEN_ShareFor_PC$"); - row.put("Path", "C:\\Windows\\Temp\\SEN_ShareFor_PC$"); - result.add(row); - } - - mockedFactory - .when(() -> WinRMExecutorFactory.createInstance(any(WinRMEndpoint.class), anyLong(), isNull(), isNull())) - .thenReturn(executor); - - doReturn(result).when(executor).executeWql(eq(wqlQuery), anyLong()); + // End to end: executeWql -> WinRMExecutorFactory -> LightWinRMService -> WsmanClient -> + // real NTLM handshake and message encryption against the in-process WSMan server. + try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "s3cret-Passw0rd")) { + enqueueEnumeration( + server, + instance("Win32_Share", "Name", "C$", "Path", "C:\\"), + instance("Win32_Share", "Name", "SEN_ShareFor_PC$", "Path", "C:\\Windows\\Temp\\SEN_ShareFor_PC$") + ); final WinRMWqlExecutor actual = executeWql( null, - hostname, - null, - username, - password, + "127.0.0.1", + server.port(), + "FAKE\\user", + "s3cret-Passw0rd".toCharArray(), null, wqlQuery, - timeout, + 30 * 1000L, null, null ); + // Headers keep the order of the WQL SELECT clause, and each row is mapped onto it assertEquals(asList("Name", "Path"), actual.getHeaders()); - - final List> rows = asList( - asList("C$", "C:\\"), - asList("SEN_ShareFor_PC$", "C:\\Windows\\Temp\\SEN_ShareFor_PC$") + assertEquals( + asList(asList("C$", "C:\\"), asList("SEN_ShareFor_PC$", "C:\\Windows\\Temp\\SEN_ShareFor_PC$")), + actual.getRows() ); + assertTrue(actual.getExecutionTime() >= 0); - assertEquals(rows, actual.getRows()); + // The query the factory-created executor put on the wire is the caller's, verbatim + final List requests = server.decryptedRequests(); + assertEquals(1, requests.size(), () -> String.join("\n---\n", requests)); + assertTrue(requests.get(0).contains(wqlQuery), requests.get(0)); } } }