Skip to content

Commit

Permalink
Use testcontainers (hierynomus#741)
Browse files Browse the repository at this point in the history
* Replace abstract class IntegrationBaseSpec with composition through IntegrationTestUtil

* Switch to testcontainers in integration tests

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 authored Nov 10, 2021
1 parent 8a66dc5 commit d5805a6
Show file tree
Hide file tree
Showing 14 changed files with 280 additions and 151 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.

42 changes: 0 additions & 42 deletions src/itest/groovy/com/hierynomus/sshj/IntegrationBaseSpec.groovy

This file was deleted.

18 changes: 12 additions & 6 deletions src/itest/groovy/com/hierynomus/sshj/IntegrationSpec.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +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 IntegrationBaseSpec {
class IntegrationSpec extends Specification {
@Shared
@ClassRule
SshdContainer sshd

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

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

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

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

then:
thrown(TransportException.class)
Expand All @@ -59,11 +65,11 @@ class IntegrationSpec extends IntegrationBaseSpec {
@Unroll
def "should authenticate with key #key"() {
given:
SSHClient client = 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")
client.authPublickey(USERNAME, keyProvider)
client.authPublickey(IntegrationTestUtil.USERNAME, keyProvider)

then:
client.isAuthenticated()
Expand All @@ -83,7 +89,7 @@ class IntegrationSpec extends IntegrationBaseSpec {

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

when:
client.authPublickey("sshj", "src/itest/resources/keyfiles/id_unknown_key")
Expand Down
21 changes: 21 additions & 0 deletions src/itest/groovy/com/hierynomus/sshj/IntegrationTestUtil.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* 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

class IntegrationTestUtil {
static final String USERNAME = "sshj"
static final String KEYFILE = "src/itest/resources/keyfiles/id_rsa"
}
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());
}
}
Loading

0 comments on commit d5805a6

Please sign in to comment.