Skip to content

Commit

Permalink
Switch to testcontainers in integration tests
Browse files Browse the repository at this point in the history
It allows running different SSH servers with different configurations in tests, giving ability to cover more bugs, like mentioned in hierynomus#733.
  • Loading branch information
vladimirlagunov committed Nov 10, 2021
1 parent 0ded0ca commit 3ae6a89
Show file tree
Hide file tree
Showing 13 changed files with 230 additions and 102 deletions.
43 changes: 1 addition & 42 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import java.text.SimpleDateFormat
import com.bmuschko.gradle.docker.tasks.container.*
import com.bmuschko.gradle.docker.tasks.image.*

plugins {
id "java"
id "groovy"
Expand Down Expand Up @@ -60,7 +56,7 @@ dependencies {
testRuntimeOnly "ch.qos.logback:logback-classic:1.2.6"
testImplementation 'org.glassfish.grizzly:grizzly-http-server:2.4.4'
testImplementation 'org.apache.httpcomponents:httpclient:4.5.9'

testImplementation 'org.testcontainers:testcontainers:1.16.2'
}

license {
Expand Down Expand Up @@ -276,48 +272,11 @@ jacocoTestReport {
}
}


task buildItestImage(type: DockerBuildImage) {
inputDir = file('src/itest/docker-image')
images.add('sshj/sshd-itest:latest')
}

task createItestContainer(type: DockerCreateContainer) {
dependsOn buildItestImage
targetImageId buildItestImage.getImageId()
hostConfig.portBindings = ['2222:22']
hostConfig.autoRemove = true
}

task startItestContainer(type: DockerStartContainer) {
dependsOn createItestContainer
targetContainerId createItestContainer.getContainerId()
}

task logItestContainer(type: DockerLogsContainer) {
dependsOn createItestContainer
targetContainerId createItestContainer.getContainerId()
showTimestamps = true
stdErr = true
stdOut = true
tailAll = true
}

task stopItestContainer(type: DockerStopContainer) {
targetContainerId createItestContainer.getContainerId()
}

task forkedUploadRelease(type: GradleBuild) {
buildFile = project.buildFile
tasks = ["clean", "publishToSonatype", "closeAndReleaseSonatypeStagingRepository"]
}

project.tasks.integrationTest.dependsOn(startItestContainer)
project.tasks.integrationTest.finalizedBy(stopItestContainer)

// Being enabled, it pollutes logs on CI. Uncomment when debugging some test to get sshd logs.
// project.tasks.stopItestContainer.dependsOn(logItestContainer)

project.tasks.release.dependsOn([project.tasks.integrationTest, project.tasks.build])
project.tasks.release.finalizedBy(project.tasks.forkedUploadRelease)
project.tasks.jacocoTestReport.dependsOn(project.tasks.test)
Expand Down
24 changes: 0 additions & 24 deletions src/itest/docker-image/Dockerfile

This file was deleted.

13 changes: 9 additions & 4 deletions src/itest/groovy/com/hierynomus/sshj/IntegrationSpec.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,15 @@ import net.schmizz.sshj.DefaultConfig
import net.schmizz.sshj.SSHClient
import net.schmizz.sshj.transport.TransportException
import net.schmizz.sshj.userauth.UserAuthException
import org.junit.ClassRule
import spock.lang.Shared
import spock.lang.Specification
import spock.lang.Unroll

class IntegrationSpec extends Specification {
@Shared
@ClassRule
SshdContainer sshd

@Unroll
def "should accept correct key for #signatureName"() {
Expand All @@ -34,7 +39,7 @@ class IntegrationSpec extends Specification {
sshClient.addHostKeyVerifier(fingerprint) // test-containers/ssh_host_ecdsa_key's fingerprint

when:
sshClient.connect(IntegrationTestUtil.SERVER_IP, IntegrationTestUtil.DOCKER_PORT)
sshClient.connect(sshd.containerIpAddress, sshd.firstMappedPort)

then:
sshClient.isConnected()
Expand All @@ -51,7 +56,7 @@ class IntegrationSpec extends Specification {
sshClient.addHostKeyVerifier("d4:6a:a9:52:05:ab:b5:48:dd:73:60:18:0c:3a:f0:a3")

when:
sshClient.connect(IntegrationTestUtil.SERVER_IP, IntegrationTestUtil.DOCKER_PORT)
sshClient.connect(sshd.containerIpAddress, sshd.firstMappedPort)

then:
thrown(TransportException.class)
Expand All @@ -60,7 +65,7 @@ class IntegrationSpec extends Specification {
@Unroll
def "should authenticate with key #key"() {
given:
SSHClient client = IntegrationTestUtil.getConnectedClient()
SSHClient client = sshd.getConnectedClient()

when:
def keyProvider = passphrase != null ? client.loadKeys("src/itest/resources/keyfiles/$key", passphrase) : client.loadKeys("src/itest/resources/keyfiles/$key")
Expand All @@ -84,7 +89,7 @@ class IntegrationSpec extends Specification {

def "should not authenticate with wrong key"() {
given:
SSHClient client = IntegrationTestUtil.getConnectedClient()
SSHClient client = sshd.getConnectedClient()

when:
client.authPublickey("sshj", "src/itest/resources/keyfiles/id_unknown_key")
Expand Down
19 changes: 0 additions & 19 deletions src/itest/groovy/com/hierynomus/sshj/IntegrationTestUtil.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,7 @@
*/
package com.hierynomus.sshj

import net.schmizz.sshj.Config
import net.schmizz.sshj.DefaultConfig
import net.schmizz.sshj.SSHClient
import net.schmizz.sshj.transport.verification.PromiscuousVerifier

class IntegrationTestUtil {
static final int DOCKER_PORT = 2222
static final String USERNAME = "sshj"
static final String KEYFILE = "src/itest/resources/keyfiles/id_rsa"
final static String SERVER_IP = System.getProperty("serverIP", "127.0.0.1")

static SSHClient getConnectedClient(Config config) {
SSHClient sshClient = new SSHClient(config)
sshClient.addHostKeyVerifier(new PromiscuousVerifier())
sshClient.connect(SERVER_IP, DOCKER_PORT)

return sshClient
}

static SSHClient getConnectedClient() throws IOException {
return getConnectedClient(new DefaultConfig())
}
}
77 changes: 77 additions & 0 deletions src/itest/groovy/com/hierynomus/sshj/SshServerWaitStrategy.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright (C)2009 - SSHJ Contributors
*
* 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.
*/
package com.hierynomus.sshj;

import org.testcontainers.containers.wait.strategy.WaitStrategy;
import org.testcontainers.containers.wait.strategy.WaitStrategyTarget;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Arrays;

/**
* A wait strategy designed for {@link SshdContainer} to wait until the SSH server is ready, to avoid races when a test
* tries to connect to a server before the server has started.
*/
public class SshServerWaitStrategy implements WaitStrategy {
private Duration startupTimeout = Duration.ofMinutes(1);

@Override
public void waitUntilReady(WaitStrategyTarget waitStrategyTarget) {
long expectedEnd = System.nanoTime() + startupTimeout.toNanos();
while (true) {
long attemptStart = System.nanoTime();
IOException error = null;
byte[] buffer = new byte[7];
try (Socket socket = new Socket()) {
socket.setSoTimeout(500);
socket.connect(new InetSocketAddress(
waitStrategyTarget.getHost(), waitStrategyTarget.getFirstMappedPort()));
// Haven't seen any SSH server that sends the version in two or more packets.
//noinspection ResultOfMethodCallIgnored
socket.getInputStream().read(buffer);
if (!Arrays.equals(buffer, "SSH-2.0".getBytes(StandardCharsets.UTF_8))) {
error = new IOException("The version message doesn't look like an SSH server version");
}
} catch (IOException err) {
error = err;
}

if (error == null) {
break;
} else if (System.nanoTime() >= expectedEnd) {
throw new RuntimeException(error);
}

try {
//noinspection BusyWait
Thread.sleep(Math.max(0L, 500L - (System.nanoTime() - attemptStart) / 1_000_000));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}

@Override
public WaitStrategy withStartupTimeout(Duration startupTimeout) {
this.startupTimeout = startupTimeout;
return this;
}
}
84 changes: 84 additions & 0 deletions src/itest/groovy/com/hierynomus/sshj/SshdContainer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* Copyright (C)2009 - SSHJ Contributors
*
* 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.
*/
package com.hierynomus.sshj;

import net.schmizz.sshj.Config;
import net.schmizz.sshj.DefaultConfig;
import net.schmizz.sshj.SSHClient;
import net.schmizz.sshj.transport.verification.PromiscuousVerifier;
import org.jetbrains.annotations.NotNull;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.images.builder.ImageFromDockerfile;
import org.testcontainers.images.builder.dockerfile.DockerfileBuilder;

import java.io.IOException;
import java.nio.file.Paths;
import java.util.concurrent.Future;

/**
* A JUnit4 rule for launching a generic SSH server container.
*/
public class SshdContainer extends GenericContainer<SshdContainer> {
@SuppressWarnings("unused") // Used dynamically by Spock
public SshdContainer() {
this(new ImageFromDockerfile()
.withDockerfileFromBuilder(SshdContainer::defaultDockerfileBuilder)
.withFileFromPath(".", Paths.get("src/itest/docker-image")));
}

public SshdContainer(@NotNull Future<String> future) {
super(future);
withExposedPorts(22);
setWaitStrategy(new SshServerWaitStrategy());
}

public static void defaultDockerfileBuilder(@NotNull DockerfileBuilder builder) {
builder.from("sickp/alpine-sshd:7.5-r2");

builder.add("authorized_keys", "/home/sshj/.ssh/authorized_keys");

builder.add("test-container/ssh_host_ecdsa_key", "/etc/ssh/ssh_host_ecdsa_key");
builder.add("test-container/ssh_host_ecdsa_key.pub", "/etc/ssh/ssh_host_ecdsa_key.pub");
builder.add("test-container/ssh_host_ed25519_key", "/etc/ssh/ssh_host_ed25519_key");
builder.add("test-container/ssh_host_ed25519_key.pub", "/etc/ssh/ssh_host_ed25519_key.pub");
builder.add("test-container/sshd_config", "/etc/ssh/sshd_config");
builder.copy("test-container/trusted_ca_keys", "/etc/ssh/trusted_ca_keys");
builder.copy("test-container/host_keys/*", "/etc/ssh/");

builder.run("apk add --no-cache tini"
+ " && echo \"root:smile\" | chpasswd"
+ " && adduser -D -s /bin/ash sshj"
+ " && passwd -u sshj"
+ " && echo \"sshj:ultrapassword\" | chpasswd"
+ " && chmod 600 /home/sshj/.ssh/authorized_keys"
+ " && chmod 600 /etc/ssh/ssh_host_*_key"
+ " && chmod 644 /etc/ssh/*.pub"
+ " && chown -R sshj:sshj /home/sshj");
builder.entryPoint("/sbin/tini", "/entrypoint.sh", "-o", "LogLevel=DEBUG2");
}

public SSHClient getConnectedClient(Config config) throws IOException {
SSHClient sshClient = new SSHClient(config);
sshClient.addHostKeyVerifier(new PromiscuousVerifier());
sshClient.connect("127.0.0.1", getFirstMappedPort());

return sshClient;
}

public SSHClient getConnectedClient() throws IOException {
return getConnectedClient(new DefaultConfig());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,27 @@
package com.hierynomus.sshj.sftp

import com.hierynomus.sshj.IntegrationTestUtil
import com.hierynomus.sshj.SshdContainer
import net.schmizz.sshj.SSHClient
import net.schmizz.sshj.sftp.OpenMode
import net.schmizz.sshj.sftp.RemoteFile
import net.schmizz.sshj.sftp.SFTPClient
import org.junit.ClassRule
import spock.lang.Shared
import spock.lang.Specification

import java.nio.charset.StandardCharsets

import static org.codehaus.groovy.runtime.IOGroovyMethods.withCloseable

class FileWriteSpec extends Specification {
@Shared
@ClassRule
SshdContainer sshd

def "should append to file (GH issue #390)"() {
given:
SSHClient client = IntegrationTestUtil.getConnectedClient()
SSHClient client = sshd.getConnectedClient()
client.authPublickey("sshj", "src/test/resources/id_rsa")
SFTPClient sftp = client.newSFTPClient()
def file = "/home/sshj/test.txt"
Expand Down
Loading

0 comments on commit 3ae6a89

Please sign in to comment.