Skip to content

Commit 3ae6a89

Browse files
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.
1 parent 0ded0ca commit 3ae6a89

13 files changed

+230
-102
lines changed

build.gradle

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
import java.text.SimpleDateFormat
2-
import com.bmuschko.gradle.docker.tasks.container.*
3-
import com.bmuschko.gradle.docker.tasks.image.*
4-
51
plugins {
62
id "java"
73
id "groovy"
@@ -60,7 +56,7 @@ dependencies {
6056
testRuntimeOnly "ch.qos.logback:logback-classic:1.2.6"
6157
testImplementation 'org.glassfish.grizzly:grizzly-http-server:2.4.4'
6258
testImplementation 'org.apache.httpcomponents:httpclient:4.5.9'
63-
59+
testImplementation 'org.testcontainers:testcontainers:1.16.2'
6460
}
6561

6662
license {
@@ -276,48 +272,11 @@ jacocoTestReport {
276272
}
277273
}
278274

279-
280-
task buildItestImage(type: DockerBuildImage) {
281-
inputDir = file('src/itest/docker-image')
282-
images.add('sshj/sshd-itest:latest')
283-
}
284-
285-
task createItestContainer(type: DockerCreateContainer) {
286-
dependsOn buildItestImage
287-
targetImageId buildItestImage.getImageId()
288-
hostConfig.portBindings = ['2222:22']
289-
hostConfig.autoRemove = true
290-
}
291-
292-
task startItestContainer(type: DockerStartContainer) {
293-
dependsOn createItestContainer
294-
targetContainerId createItestContainer.getContainerId()
295-
}
296-
297-
task logItestContainer(type: DockerLogsContainer) {
298-
dependsOn createItestContainer
299-
targetContainerId createItestContainer.getContainerId()
300-
showTimestamps = true
301-
stdErr = true
302-
stdOut = true
303-
tailAll = true
304-
}
305-
306-
task stopItestContainer(type: DockerStopContainer) {
307-
targetContainerId createItestContainer.getContainerId()
308-
}
309-
310275
task forkedUploadRelease(type: GradleBuild) {
311276
buildFile = project.buildFile
312277
tasks = ["clean", "publishToSonatype", "closeAndReleaseSonatypeStagingRepository"]
313278
}
314279

315-
project.tasks.integrationTest.dependsOn(startItestContainer)
316-
project.tasks.integrationTest.finalizedBy(stopItestContainer)
317-
318-
// Being enabled, it pollutes logs on CI. Uncomment when debugging some test to get sshd logs.
319-
// project.tasks.stopItestContainer.dependsOn(logItestContainer)
320-
321280
project.tasks.release.dependsOn([project.tasks.integrationTest, project.tasks.build])
322281
project.tasks.release.finalizedBy(project.tasks.forkedUploadRelease)
323282
project.tasks.jacocoTestReport.dependsOn(project.tasks.test)

src/itest/docker-image/Dockerfile

Lines changed: 0 additions & 24 deletions
This file was deleted.

src/itest/groovy/com/hierynomus/sshj/IntegrationSpec.groovy

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,15 @@ import net.schmizz.sshj.DefaultConfig
2020
import net.schmizz.sshj.SSHClient
2121
import net.schmizz.sshj.transport.TransportException
2222
import net.schmizz.sshj.userauth.UserAuthException
23+
import org.junit.ClassRule
24+
import spock.lang.Shared
2325
import spock.lang.Specification
2426
import spock.lang.Unroll
2527

2628
class IntegrationSpec extends Specification {
29+
@Shared
30+
@ClassRule
31+
SshdContainer sshd
2732

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

3641
when:
37-
sshClient.connect(IntegrationTestUtil.SERVER_IP, IntegrationTestUtil.DOCKER_PORT)
42+
sshClient.connect(sshd.containerIpAddress, sshd.firstMappedPort)
3843

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

5358
when:
54-
sshClient.connect(IntegrationTestUtil.SERVER_IP, IntegrationTestUtil.DOCKER_PORT)
59+
sshClient.connect(sshd.containerIpAddress, sshd.firstMappedPort)
5560

5661
then:
5762
thrown(TransportException.class)
@@ -60,7 +65,7 @@ class IntegrationSpec extends Specification {
6065
@Unroll
6166
def "should authenticate with key #key"() {
6267
given:
63-
SSHClient client = IntegrationTestUtil.getConnectedClient()
68+
SSHClient client = sshd.getConnectedClient()
6469

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

8590
def "should not authenticate with wrong key"() {
8691
given:
87-
SSHClient client = IntegrationTestUtil.getConnectedClient()
92+
SSHClient client = sshd.getConnectedClient()
8893

8994
when:
9095
client.authPublickey("sshj", "src/itest/resources/keyfiles/id_unknown_key")

src/itest/groovy/com/hierynomus/sshj/IntegrationTestUtil.groovy

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,7 @@
1515
*/
1616
package com.hierynomus.sshj
1717

18-
import net.schmizz.sshj.Config
19-
import net.schmizz.sshj.DefaultConfig
20-
import net.schmizz.sshj.SSHClient
21-
import net.schmizz.sshj.transport.verification.PromiscuousVerifier
22-
2318
class IntegrationTestUtil {
24-
static final int DOCKER_PORT = 2222
2519
static final String USERNAME = "sshj"
2620
static final String KEYFILE = "src/itest/resources/keyfiles/id_rsa"
27-
final static String SERVER_IP = System.getProperty("serverIP", "127.0.0.1")
28-
29-
static SSHClient getConnectedClient(Config config) {
30-
SSHClient sshClient = new SSHClient(config)
31-
sshClient.addHostKeyVerifier(new PromiscuousVerifier())
32-
sshClient.connect(SERVER_IP, DOCKER_PORT)
33-
34-
return sshClient
35-
}
36-
37-
static SSHClient getConnectedClient() throws IOException {
38-
return getConnectedClient(new DefaultConfig())
39-
}
4021
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright (C)2009 - SSHJ Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.hierynomus.sshj;
17+
18+
import org.testcontainers.containers.wait.strategy.WaitStrategy;
19+
import org.testcontainers.containers.wait.strategy.WaitStrategyTarget;
20+
21+
import java.io.IOException;
22+
import java.net.InetSocketAddress;
23+
import java.net.Socket;
24+
import java.nio.charset.StandardCharsets;
25+
import java.time.Duration;
26+
import java.util.Arrays;
27+
28+
/**
29+
* A wait strategy designed for {@link SshdContainer} to wait until the SSH server is ready, to avoid races when a test
30+
* tries to connect to a server before the server has started.
31+
*/
32+
public class SshServerWaitStrategy implements WaitStrategy {
33+
private Duration startupTimeout = Duration.ofMinutes(1);
34+
35+
@Override
36+
public void waitUntilReady(WaitStrategyTarget waitStrategyTarget) {
37+
long expectedEnd = System.nanoTime() + startupTimeout.toNanos();
38+
while (true) {
39+
long attemptStart = System.nanoTime();
40+
IOException error = null;
41+
byte[] buffer = new byte[7];
42+
try (Socket socket = new Socket()) {
43+
socket.setSoTimeout(500);
44+
socket.connect(new InetSocketAddress(
45+
waitStrategyTarget.getHost(), waitStrategyTarget.getFirstMappedPort()));
46+
// Haven't seen any SSH server that sends the version in two or more packets.
47+
//noinspection ResultOfMethodCallIgnored
48+
socket.getInputStream().read(buffer);
49+
if (!Arrays.equals(buffer, "SSH-2.0".getBytes(StandardCharsets.UTF_8))) {
50+
error = new IOException("The version message doesn't look like an SSH server version");
51+
}
52+
} catch (IOException err) {
53+
error = err;
54+
}
55+
56+
if (error == null) {
57+
break;
58+
} else if (System.nanoTime() >= expectedEnd) {
59+
throw new RuntimeException(error);
60+
}
61+
62+
try {
63+
//noinspection BusyWait
64+
Thread.sleep(Math.max(0L, 500L - (System.nanoTime() - attemptStart) / 1_000_000));
65+
} catch (InterruptedException e) {
66+
Thread.currentThread().interrupt();
67+
break;
68+
}
69+
}
70+
}
71+
72+
@Override
73+
public WaitStrategy withStartupTimeout(Duration startupTimeout) {
74+
this.startupTimeout = startupTimeout;
75+
return this;
76+
}
77+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright (C)2009 - SSHJ Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.hierynomus.sshj;
17+
18+
import net.schmizz.sshj.Config;
19+
import net.schmizz.sshj.DefaultConfig;
20+
import net.schmizz.sshj.SSHClient;
21+
import net.schmizz.sshj.transport.verification.PromiscuousVerifier;
22+
import org.jetbrains.annotations.NotNull;
23+
import org.testcontainers.containers.GenericContainer;
24+
import org.testcontainers.images.builder.ImageFromDockerfile;
25+
import org.testcontainers.images.builder.dockerfile.DockerfileBuilder;
26+
27+
import java.io.IOException;
28+
import java.nio.file.Paths;
29+
import java.util.concurrent.Future;
30+
31+
/**
32+
* A JUnit4 rule for launching a generic SSH server container.
33+
*/
34+
public class SshdContainer extends GenericContainer<SshdContainer> {
35+
@SuppressWarnings("unused") // Used dynamically by Spock
36+
public SshdContainer() {
37+
this(new ImageFromDockerfile()
38+
.withDockerfileFromBuilder(SshdContainer::defaultDockerfileBuilder)
39+
.withFileFromPath(".", Paths.get("src/itest/docker-image")));
40+
}
41+
42+
public SshdContainer(@NotNull Future<String> future) {
43+
super(future);
44+
withExposedPorts(22);
45+
setWaitStrategy(new SshServerWaitStrategy());
46+
}
47+
48+
public static void defaultDockerfileBuilder(@NotNull DockerfileBuilder builder) {
49+
builder.from("sickp/alpine-sshd:7.5-r2");
50+
51+
builder.add("authorized_keys", "/home/sshj/.ssh/authorized_keys");
52+
53+
builder.add("test-container/ssh_host_ecdsa_key", "/etc/ssh/ssh_host_ecdsa_key");
54+
builder.add("test-container/ssh_host_ecdsa_key.pub", "/etc/ssh/ssh_host_ecdsa_key.pub");
55+
builder.add("test-container/ssh_host_ed25519_key", "/etc/ssh/ssh_host_ed25519_key");
56+
builder.add("test-container/ssh_host_ed25519_key.pub", "/etc/ssh/ssh_host_ed25519_key.pub");
57+
builder.add("test-container/sshd_config", "/etc/ssh/sshd_config");
58+
builder.copy("test-container/trusted_ca_keys", "/etc/ssh/trusted_ca_keys");
59+
builder.copy("test-container/host_keys/*", "/etc/ssh/");
60+
61+
builder.run("apk add --no-cache tini"
62+
+ " && echo \"root:smile\" | chpasswd"
63+
+ " && adduser -D -s /bin/ash sshj"
64+
+ " && passwd -u sshj"
65+
+ " && echo \"sshj:ultrapassword\" | chpasswd"
66+
+ " && chmod 600 /home/sshj/.ssh/authorized_keys"
67+
+ " && chmod 600 /etc/ssh/ssh_host_*_key"
68+
+ " && chmod 644 /etc/ssh/*.pub"
69+
+ " && chown -R sshj:sshj /home/sshj");
70+
builder.entryPoint("/sbin/tini", "/entrypoint.sh", "-o", "LogLevel=DEBUG2");
71+
}
72+
73+
public SSHClient getConnectedClient(Config config) throws IOException {
74+
SSHClient sshClient = new SSHClient(config);
75+
sshClient.addHostKeyVerifier(new PromiscuousVerifier());
76+
sshClient.connect("127.0.0.1", getFirstMappedPort());
77+
78+
return sshClient;
79+
}
80+
81+
public SSHClient getConnectedClient() throws IOException {
82+
return getConnectedClient(new DefaultConfig());
83+
}
84+
}

src/itest/groovy/com/hierynomus/sshj/sftp/FileWriteSpec.groovy

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,27 @@
1616
package com.hierynomus.sshj.sftp
1717

1818
import com.hierynomus.sshj.IntegrationTestUtil
19+
import com.hierynomus.sshj.SshdContainer
1920
import net.schmizz.sshj.SSHClient
2021
import net.schmizz.sshj.sftp.OpenMode
2122
import net.schmizz.sshj.sftp.RemoteFile
2223
import net.schmizz.sshj.sftp.SFTPClient
24+
import org.junit.ClassRule
25+
import spock.lang.Shared
2326
import spock.lang.Specification
2427

2528
import java.nio.charset.StandardCharsets
2629

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

2932
class FileWriteSpec extends Specification {
33+
@Shared
34+
@ClassRule
35+
SshdContainer sshd
3036

3137
def "should append to file (GH issue #390)"() {
3238
given:
33-
SSHClient client = IntegrationTestUtil.getConnectedClient()
39+
SSHClient client = sshd.getConnectedClient()
3440
client.authPublickey("sshj", "src/test/resources/id_rsa")
3541
SFTPClient sftp = client.newSFTPClient()
3642
def file = "/home/sshj/test.txt"

0 commit comments

Comments
 (0)