Skip to content

Commit d5805a6

Browse files
Use testcontainers (#741)
* 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 #733.
1 parent 8a66dc5 commit d5805a6

14 files changed

+280
-151
lines changed

build.gradle

+1-42
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

-24
This file was deleted.

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

-42
This file was deleted.

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

+12-6
Original file line numberDiff line numberDiff line change
@@ -20,9 +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
25+
import spock.lang.Specification
2326
import spock.lang.Unroll
2427

25-
class IntegrationSpec extends IntegrationBaseSpec {
28+
class IntegrationSpec extends Specification {
29+
@Shared
30+
@ClassRule
31+
SshdContainer sshd
2632

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

3541
when:
36-
sshClient.connect(SERVER_IP, DOCKER_PORT)
42+
sshClient.connect(sshd.containerIpAddress, sshd.firstMappedPort)
3743

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

5258
when:
53-
sshClient.connect(SERVER_IP, DOCKER_PORT)
59+
sshClient.connect(sshd.containerIpAddress, sshd.firstMappedPort)
5460

5561
then:
5662
thrown(TransportException.class)
@@ -59,11 +65,11 @@ class IntegrationSpec extends IntegrationBaseSpec {
5965
@Unroll
6066
def "should authenticate with key #key"() {
6167
given:
62-
SSHClient client = getConnectedClient()
68+
SSHClient client = sshd.getConnectedClient()
6369

6470
when:
6571
def keyProvider = passphrase != null ? client.loadKeys("src/itest/resources/keyfiles/$key", passphrase) : client.loadKeys("src/itest/resources/keyfiles/$key")
66-
client.authPublickey(USERNAME, keyProvider)
72+
client.authPublickey(IntegrationTestUtil.USERNAME, keyProvider)
6773

6874
then:
6975
client.isAuthenticated()
@@ -83,7 +89,7 @@ class IntegrationSpec extends IntegrationBaseSpec {
8389

8490
def "should not authenticate with wrong key"() {
8591
given:
86-
SSHClient client = getConnectedClient()
92+
SSHClient client = sshd.getConnectedClient()
8793

8894
when:
8995
client.authPublickey("sshj", "src/itest/resources/keyfiles/id_unknown_key")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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+
class IntegrationTestUtil {
19+
static final String USERNAME = "sshj"
20+
static final String KEYFILE = "src/itest/resources/keyfiles/id_rsa"
21+
}
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+
}
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+
}

0 commit comments

Comments
 (0)