Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@
<jsch.version>2.28.0</jsch.version>
<commons-io.version>2.21.0</commons-io.version>
<java-utils.version>4.0.0</java-utils.version>
<!-- TestNG 7.6+ requires Java 11; 7.5.1 is the last Java 8 compatible release -->
<testng.version>7.5.1</testng.version>
<testng-foundation.version>6.0.0-j8</testng-foundation.version>
<sshd.version>2.17.1</sshd.version>
<slf4j.version>2.0.17</slf4j.version>
<compiler-plugin.version>3.14.0</compiler-plugin.version>
Expand Down Expand Up @@ -85,9 +84,9 @@
<version>${settings.version}</version>
</dependency>
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>${testng.version}</version>
<groupId>com.nordstrom.tools</groupId>
<artifactId>testng-foundation</artifactId>
<version>${testng-foundation.version}</version>
<scope>test</scope>
</dependency>
<dependency>
Expand Down
77 changes: 33 additions & 44 deletions src/test/java/com/nordstrom/remote/SshUtilsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.util.Arrays;
import java.util.Base64;
Expand All @@ -31,53 +30,48 @@
import org.apache.sshd.server.channel.ChannelSession;
import org.apache.sshd.server.command.Command;
import org.apache.sshd.sftp.server.SftpSubsystemFactory;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;

import com.jcraft.jsch.Buffer;
import com.jcraft.jsch.JSch;
import com.nordstrom.remote.RemoteConfig.RemoteSettings;

public class SshUtilsTest {
private SshServer sshd;
private int port;
private Path mockRemoteRoot;
private PublicKey clientPublicKey;
private Path clientPrivateKeyPath;
public class SshUtilsTest extends TestNgBase {
private final String USER = "tester";
private final String PASS = "password123";

@BeforeClass
@BeforeMethod
public void startServer() throws Exception {
sshd = SshServer.setUpDefaultServer();
sshd.setPort(0);
setSshServer(SshServer.setUpDefaultServer());
getSshServer().setPort(0);

// 2. Setup Host Key (The server's identity)
KeyPairGenerator hostGen = KeyPairGenerator.getInstance("RSA");
hostGen.initialize(2048);
KeyPair hostPair = hostGen.generateKeyPair();
sshd.setKeyPairProvider(KeyPairProvider.wrap(hostPair));
getSshServer().setKeyPairProvider(KeyPairProvider.wrap(hostPair));

generateClientIdentity();

// 3. Integrated Authenticators
sshd.setPasswordAuthenticator((u, p, s) -> USER.equals(u) && PASS.equals(p));
getSshServer().setPasswordAuthenticator((u, p, s) -> USER.equals(u) && PASS.equals(p));

sshd.setPublickeyAuthenticator((u, key, s) -> {
getSshServer().setPublickeyAuthenticator((u, key, s) -> {
if (!USER.equals(u)) return false;

// Compare encoded byte arrays to avoid object-type mismatches
return Arrays.equals(key.getEncoded(), this.clientPublicKey.getEncoded());
return Arrays.equals(key.getEncoded(), getPublicKey().getEncoded());
});

// Requires both publickey AND password to succeed
sshd.getProperties().put(CoreModuleProperties.AUTH_METHODS.getName(), "publickey,password");
// Requires both public key AND password to succeed
getSshServer().getProperties().put(CoreModuleProperties.AUTH_METHODS.getName(), "publickey,password");

mockRemoteRoot = Files.createTempDirectory("ssh_remote_root");
sshd.setFileSystemFactory(new VirtualFileSystemFactory(mockRemoteRoot));
setRemoteRoot(Files.createTempDirectory("ssh_remote_root"));
getSshServer().setFileSystemFactory(new VirtualFileSystemFactory(getRemoteRoot()));

sshd.setCommandFactory((channel, command) -> new Command() {
getSshServer().setCommandFactory((channel, command) -> new Command() {
private OutputStream out;
private ExitCallback callback;

Expand Down Expand Up @@ -106,30 +100,25 @@ public void start(ChannelSession channel, Environment env) throws IOException {
@Override public void setErrorStream(OutputStream err) {}
});

sshd.setSubsystemFactories(Collections.singletonList(new SftpSubsystemFactory()));
getSshServer().setSubsystemFactories(Collections.singletonList(new SftpSubsystemFactory()));

sshd.start();
port = sshd.getPort();
updateKnownHostsWithActualPort(this.port, hostPair);
getSshServer().start();
setServerPort(getSshServer().getPort());
updateKnownHostsWithActualPort(getServerPort(), hostPair);
}

@AfterClass(alwaysRun = true)
@AfterMethod
public void stopServer() throws IOException {
try {
if (sshd != null && sshd.isStarted()) {
sshd.close(true).await(1000);
if (getSshServer() != null && getSshServer().isStarted()) {
getSshServer().close(true).await(1000);
System.out.println("[INFO] Mock SSH Server stopped successfully.");
}
if (clientPrivateKeyPath != null) {
Files.walk(clientPrivateKeyPath.getParent())
.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEach(File::delete);
}
} catch (IOException e) {
System.err.println("[WARN] SSH Server shutdown interrupted: " + e.getMessage());
} finally {
recursiveDelete(mockRemoteRoot);
recursiveDelete(getPrivateKeyPath());
recursiveDelete(getRemoteRoot());
}
}

Expand All @@ -147,8 +136,8 @@ private void recursiveDelete(final Path path) {

@Test
public void testExecCommand() {
String remoteUri = String.format("ssh://%s:%s@localhost:%d", USER, PASS, port);
System.setProperty(RemoteSettings.SSH_KEY_NAME.key(), this.clientPrivateKeyPath.toString());
String remoteUri = String.format("ssh://%s:%s@localhost:%d", USER, PASS, getServerPort());
System.setProperty(RemoteSettings.SSH_KEY_NAME.key(), getPrivateKeyPath().toString());
String result = SshUtils.exec(remoteUri, "echo 'Remote-Session-Test'");

assertEquals(result.trim(), "Remote-Session-Test", "Command result mismatch");
Expand All @@ -160,11 +149,11 @@ public void testSftpUpload() throws IOException {
Files.write(localPath, "Hello SFTP".getBytes());

String sourceUri = localPath.toUri().toString();
String remoteUri = String.format("ssh://%s:%s@localhost:%d", USER, PASS, port);
System.setProperty(RemoteSettings.SSH_KEY_NAME.key(), this.clientPrivateKeyPath.toString());
String remoteUri = String.format("ssh://%s:%s@localhost:%d", USER, PASS, getServerPort());
System.setProperty(RemoteSettings.SSH_KEY_NAME.key(), getPrivateKeyPath().toString());
SshUtils.sftp(sourceUri, remoteUri);

Path expectedFileOnServer = mockRemoteRoot.resolve(localPath.getFileName());
Path expectedFileOnServer = getRemoteRoot().resolve(localPath.getFileName());
assertTrue(Files.exists(expectedFileOnServer), "File should exist on the mock server");
String uploadedContent = new String(Files.readAllBytes(expectedFileOnServer));
assertEquals(uploadedContent, "Hello SFTP", "Content mismatch on remote server!");
Expand All @@ -174,14 +163,14 @@ public void testSftpUpload() throws IOException {

private void generateClientIdentity() throws Exception {
Path tempDir = Files.createTempDirectory("ssh_identity");
this.clientPrivateKeyPath = tempDir.resolve("id_rsa_test");
setPrivateKeyPath(tempDir.resolve("id_rsa_test"));

JSch jsch = new JSch();
// 1. Generate the pair using JSch's generator
com.jcraft.jsch.KeyPair kpair = com.jcraft.jsch.KeyPair.genKeyPair(jsch, com.jcraft.jsch.KeyPair.RSA, 2048);

// 2. Write the Private Key in PEM format (Crucial for JSch to read it later)
try (OutputStream os = new FileOutputStream(clientPrivateKeyPath.toFile())) {
try (OutputStream os = new FileOutputStream(getPrivateKeyPath().toFile())) {
kpair.writePrivateKey(os);
}

Expand All @@ -194,13 +183,13 @@ private void generateClientIdentity() throws Exception {
byte[] n = buf.getMPInt();

RSAPublicKeySpec spec = new RSAPublicKeySpec(new BigInteger(1, n), new BigInteger(1, e));
this.clientPublicKey = KeyFactory.getInstance("RSA").generatePublic(spec);
setPublicKey(KeyFactory.getInstance("RSA").generatePublic(spec));

kpair.dispose();
}

private void updateKnownHostsWithActualPort(int actualPort, KeyPair hostPair) throws IOException {
Path knownHostsPath = clientPrivateKeyPath.resolveSibling("known_hosts");
Path knownHostsPath = getPrivateKeyPath().resolveSibling("known_hosts");
String encodedKey = Base64.getEncoder().encodeToString(hostPair.getPublic().getEncoded());
String entry = String.format("[localhost]:%d ssh-rsa %s%n", actualPort, encodedKey);
Files.write(knownHostsPath, entry.getBytes(StandardCharsets.UTF_8));
Expand Down
155 changes: 155 additions & 0 deletions src/test/java/com/nordstrom/remote/TestNgBase.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package com.nordstrom.remote;

import java.nio.file.Path;
import java.security.PublicKey;
import java.util.Optional;

import org.apache.sshd.server.SshServer;
import org.testng.ITestResult;
import org.testng.Reporter;

import com.nordstrom.automation.testng.ExecutionFlowController;
import com.nordstrom.automation.testng.LinkedListeners;
import com.nordstrom.automation.testng.TrackedObject;

@LinkedListeners({ExecutionFlowController.class})
public abstract class TestNgBase {

/**
* This enumeration is responsible for storing and retrieving values in the attributes collection of the current
* test result, as reported by {@link Reporter#getCurrentTestResult()}.
*/
private enum TestAttribute {
SSH_SERVER("SshServer"),
SERVER_PORT("ServerPort"),
REMOTE_ROOT("RemoteRoot"),
PUBLIC_KEY("PublicKey"),
PRIVATE_KEY_PATH("PrivateKeyPath");

private String key;

/**
* Constructor for TestAttribute enumeration
*
* @param key key for this constant
*/
TestAttribute(final String key) {
this.key = key;
}

/**
* Store the specified object in the attributes collection.
*
* @param obj object to be stored; 'null' to discard value
*/
// private void set(final Object obj) {
// ITestResult result = Reporter.getCurrentTestResult();
// if (obj != null) {
// result.setAttribute(key, obj);
// } else {
// result.removeAttribute(key);
// }
// }

/**
* Store the specified object in the attributes collection, tracking reference propagation.
*
* @param obj object to be stored; 'null' to discard value and release tracked references
*/
private void track(final Object obj) {
ITestResult result = Reporter.getCurrentTestResult();
if (obj != null) {
new TrackedObject<>(result, key, obj);
} else {
Object val = result.getAttribute(key);
if (val instanceof TrackedObject) {
((TrackedObject<?>) val).release();
} else {
result.removeAttribute(key);
}
}
}

/**
* If present, get the object from the attributes collection.
*
* @return (optional) stored object
*/
private Optional<?> nab() {
Object obj;
ITestResult result = Reporter.getCurrentTestResult();
Object val = result.getAttribute(key);
if (val instanceof TrackedObject) {
obj = ((TrackedObject<?>) val).getValue();
} else {
obj = val;
}
return optionalOf(obj);
}
}

public SshServer getSshServer() {
return TestAttribute.SSH_SERVER.nab()
.map(SshServer.class::cast)
.orElseThrow(() -> new RuntimeException("Test attribute 'SSH_SERVER' not initialized"));
}

public void setSshServer(final SshServer sshServer) {
TestAttribute.SSH_SERVER.track(sshServer);
}

public Integer getServerPort() {
return TestAttribute.SERVER_PORT.nab()
.map(Integer.class::cast)
.orElseThrow(() -> new RuntimeException("Test attribute 'SERVER_PORT' not initialized"));
}

public void setServerPort(final Integer serverPort) {
TestAttribute.SERVER_PORT.track(serverPort);
}

public Path getRemoteRoot() {
return TestAttribute.REMOTE_ROOT.nab()
.map(Path.class::cast)
.orElseThrow(() -> new RuntimeException("Test attribute 'REMOTE_ROOT' not initialized"));
}

public void setRemoteRoot(final Path remoteRoot) {
TestAttribute.REMOTE_ROOT.track(remoteRoot);
}

public PublicKey getPublicKey() {
return TestAttribute.PUBLIC_KEY.nab()
.map(PublicKey.class::cast)
.orElseThrow(() -> new RuntimeException("Test attribute 'PUBLIC_KEY' not initialized"));
}

public void setPublicKey(final PublicKey publicKey) {
TestAttribute.PUBLIC_KEY.track(publicKey);
}

public Path getPrivateKeyPath() {
return TestAttribute.PRIVATE_KEY_PATH.nab()
.map(Path.class::cast)
.orElseThrow(() -> new RuntimeException("Test attribute 'PRIVATE_KEY_PATH' not initialized"));
}

public void setPrivateKeyPath(final Path privateKeyPath) {
TestAttribute.PRIVATE_KEY_PATH.track(privateKeyPath);
}

/**
* Wrap the specified object in an {@link Optional} object.
*
* @param <T> type of object to be wrapped
* @param obj object to be wrapped (may be 'null')
* @return (optional) wrapped object; empty if {@code obj} is 'null'
*/
public static <T> Optional<T> optionalOf(T obj) {
if (obj != null) {
return Optional.of(obj);
} else {
return Optional.empty();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
com.nordstrom.automation.testng.ListenerChain