Skip to content

Commit e661f6e

Browse files
committed
Use fixed clock when testing SslInfo
Closes gh-45573
1 parent 099ad30 commit e661f6e

File tree

3 files changed

+64
-88
lines changed

3 files changed

+64
-88
lines changed

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,8 @@
1919
import java.security.KeyStore;
2020
import java.security.KeyStoreException;
2121
import java.security.cert.Certificate;
22-
import java.security.cert.CertificateExpiredException;
23-
import java.security.cert.CertificateNotYetValidException;
2422
import java.security.cert.X509Certificate;
23+
import java.time.Clock;
2524
import java.time.Duration;
2625
import java.time.Instant;
2726
import java.util.Arrays;
@@ -41,6 +40,7 @@
4140
* Information about the certificates that the application uses.
4241
*
4342
* @author Jonatan Ivanov
43+
* @author Moritz Halbritter
4444
* @since 3.4.0
4545
*/
4646
public class SslInfo {
@@ -49,9 +49,16 @@ public class SslInfo {
4949

5050
private final Duration certificateValidityWarningThreshold;
5151

52+
private final Clock clock;
53+
5254
public SslInfo(SslBundles sslBundles, Duration certificateValidityWarningThreshold) {
55+
this(sslBundles, certificateValidityWarningThreshold, Clock.systemDefaultZone());
56+
}
57+
58+
SslInfo(SslBundles sslBundles, Duration certificateValidityWarningThreshold, Clock clock) {
5359
this.sslBundles = sslBundles;
5460
this.certificateValidityWarningThreshold = certificateValidityWarningThreshold;
61+
this.clock = clock;
5562
}
5663

5764
public List<BundleInfo> getBundles() {
@@ -179,25 +186,31 @@ public CertificateValidityInfo getValidity() {
179186
Instant starts = getValidityStarts();
180187
Instant ends = getValidityEnds();
181188
Duration threshold = SslInfo.this.certificateValidityWarningThreshold;
182-
try {
183-
certificate.checkValidity();
184-
return (!isExpiringSoon(certificate, threshold)) ? CertificateValidityInfo.VALID
185-
: new CertificateValidityInfo(Status.WILL_EXPIRE_SOON,
186-
"Certificate will expire within threshold (%s) at %s", threshold, ends);
187-
}
188-
catch (CertificateNotYetValidException ex) {
189-
return new CertificateValidityInfo(Status.NOT_YET_VALID, "Not valid before %s", starts);
190-
}
191-
catch (CertificateExpiredException ex) {
192-
return new CertificateValidityInfo(Status.EXPIRED, "Not valid after %s", ends);
193-
}
189+
CertificateValidityInfo.Status validity = checkValidity(starts, ends, threshold);
190+
return switch (validity) {
191+
case VALID -> CertificateValidityInfo.VALID;
192+
case EXPIRED -> new CertificateValidityInfo(Status.EXPIRED, "Not valid after %s", ends);
193+
case NOT_YET_VALID ->
194+
new CertificateValidityInfo(Status.NOT_YET_VALID, "Not valid before %s", starts);
195+
case WILL_EXPIRE_SOON -> new CertificateValidityInfo(Status.WILL_EXPIRE_SOON,
196+
"Certificate will expire within threshold (%s) at %s", threshold, ends);
197+
};
194198
});
195199
}
196200

197-
private boolean isExpiringSoon(X509Certificate certificate, Duration threshold) {
198-
Instant shouldBeValidAt = Instant.now().plus(threshold);
199-
Instant expiresAt = certificate.getNotAfter().toInstant();
200-
return shouldBeValidAt.isAfter(expiresAt);
201+
private CertificateValidityInfo.Status checkValidity(Instant starts, Instant ends, Duration threshold) {
202+
Instant now = SslInfo.this.clock.instant();
203+
if (now.isBefore(starts)) {
204+
return CertificateValidityInfo.Status.NOT_YET_VALID;
205+
}
206+
if (now.isAfter(ends)) {
207+
return CertificateValidityInfo.Status.EXPIRED;
208+
}
209+
Instant shouldBeValidAt = now.plus(threshold);
210+
if (shouldBeValidAt.isAfter(ends)) {
211+
return CertificateValidityInfo.Status.WILL_EXPIRE_SOON;
212+
}
213+
return CertificateValidityInfo.Status.VALID;
201214
}
202215

203216
private <V, R> R extract(Function<X509Certificate, V> valueExtractor, Function<V, R> resultExtractor) {

spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java

Lines changed: 33 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,13 @@
1616

1717
package org.springframework.boot.info;
1818

19-
import java.io.BufferedReader;
20-
import java.io.IOException;
21-
import java.io.InputStreamReader;
22-
import java.nio.charset.StandardCharsets;
23-
import java.nio.file.Path;
19+
import java.time.Clock;
2420
import java.time.Duration;
21+
import java.time.Instant;
22+
import java.time.ZoneId;
2523
import java.util.List;
26-
import java.util.stream.Collectors;
2724

2825
import org.junit.jupiter.api.Test;
29-
import org.junit.jupiter.api.io.TempDir;
3026

3127
import org.springframework.boot.info.SslInfo.BundleInfo;
3228
import org.springframework.boot.info.SslInfo.CertificateChainInfo;
@@ -46,9 +42,12 @@
4642
* Tests for {@link SslInfo}.
4743
*
4844
* @author Jonatan Ivanov
45+
* @author Moritz Halbritter
4946
*/
5047
class SslInfoTests {
5148

49+
private static final Clock CLOCK = Clock.fixed(Instant.parse("2025-06-18T13:00:00Z"), ZoneId.of("UTC"));
50+
5251
@Test
5352
@WithPackageResources("test.p12")
5453
void validCertificatesShouldProvideSslInfo() {
@@ -71,8 +70,8 @@ void validCertificatesShouldProvideSslInfo() {
7170
assertThat(cert1.getSerialNumber()).isNotEmpty();
7271
assertThat(cert1.getVersion()).isEqualTo("V3");
7372
assertThat(cert1.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA");
74-
assertThat(cert1.getValidityStarts()).isInThePast();
75-
assertThat(cert1.getValidityEnds()).isInTheFuture();
73+
assertThat(cert1.getValidityStarts()).isBefore(CLOCK.instant());
74+
assertThat(cert1.getValidityEnds()).isAfter(CLOCK.instant());
7675
assertThat(cert1.getValidity()).isNotNull();
7776
assertThat(cert1.getValidity().getStatus()).isSameAs(Status.VALID);
7877
assertThat(cert1.getValidity().getMessage()).isNull();
@@ -82,8 +81,8 @@ void validCertificatesShouldProvideSslInfo() {
8281
assertThat(cert2.getSerialNumber()).isNotEmpty();
8382
assertThat(cert2.getVersion()).isEqualTo("V3");
8483
assertThat(cert2.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA");
85-
assertThat(cert2.getValidityStarts()).isInThePast();
86-
assertThat(cert2.getValidityEnds()).isInTheFuture();
84+
assertThat(cert2.getValidityStarts()).isBefore(CLOCK.instant());
85+
assertThat(cert2.getValidityEnds()).isAfter(CLOCK.instant());
8786
assertThat(cert2.getValidity()).isNotNull();
8887
assertThat(cert2.getValidity().getStatus()).isSameAs(Status.VALID);
8988
assertThat(cert2.getValidity().getMessage()).isNull();
@@ -107,8 +106,8 @@ void notYetValidCertificateShouldProvideSslInfo() {
107106
assertThat(cert.getSerialNumber()).isNotEmpty();
108107
assertThat(cert.getVersion()).isEqualTo("V3");
109108
assertThat(cert.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA");
110-
assertThat(cert.getValidityStarts()).isInTheFuture();
111-
assertThat(cert.getValidityEnds()).isInTheFuture();
109+
assertThat(cert.getValidityStarts()).isAfter(CLOCK.instant());
110+
assertThat(cert.getValidityEnds()).isAfter(CLOCK.instant());
112111
assertThat(cert.getValidity()).isNotNull();
113112
assertThat(cert.getValidity().getStatus()).isSameAs(Status.NOT_YET_VALID);
114113
assertThat(cert.getValidity().getMessage()).startsWith("Not valid before");
@@ -132,18 +131,17 @@ void expiredCertificateShouldProvideSslInfo() {
132131
assertThat(cert.getSerialNumber()).isNotEmpty();
133132
assertThat(cert.getVersion()).isEqualTo("V3");
134133
assertThat(cert.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA");
135-
assertThat(cert.getValidityStarts()).isInThePast();
136-
assertThat(cert.getValidityEnds()).isInThePast();
134+
assertThat(cert.getValidityStarts()).isBefore(CLOCK.instant());
135+
assertThat(cert.getValidityEnds()).isBefore(CLOCK.instant());
137136
assertThat(cert.getValidity()).isNotNull();
138137
assertThat(cert.getValidity().getStatus()).isSameAs(Status.EXPIRED);
139138
assertThat(cert.getValidity().getMessage()).startsWith("Not valid after");
140139
}
141140

142141
@Test
143-
void soonToBeExpiredCertificateShouldProvideSslInfo(@TempDir Path tempDir)
144-
throws IOException, InterruptedException {
145-
Path keyStore = createKeyStore(tempDir);
146-
SslInfo sslInfo = createSslInfo(keyStore.toString());
142+
@WithPackageResources({ "will-expire-soon.p12" })
143+
void soonToBeExpiredCertificateShouldProvideSslInfo() {
144+
SslInfo sslInfo = createSslInfo("classpath:will-expire-soon.p12");
147145
assertThat(sslInfo.getBundles()).hasSize(1);
148146
BundleInfo bundle = sslInfo.getBundles().get(0);
149147
assertThat(bundle.getName()).isEqualTo("test-0");
@@ -158,19 +156,18 @@ void soonToBeExpiredCertificateShouldProvideSslInfo(@TempDir Path tempDir)
158156
assertThat(cert.getSerialNumber()).isNotEmpty();
159157
assertThat(cert.getVersion()).isEqualTo("V3");
160158
assertThat(cert.getSignatureAlgorithmName()).isNotEmpty();
161-
assertThat(cert.getValidityStarts()).isInThePast();
162-
assertThat(cert.getValidityEnds()).isInTheFuture();
159+
assertThat(cert.getValidityStarts()).isBefore(CLOCK.instant());
160+
assertThat(cert.getValidityEnds()).isAfter(CLOCK.instant());
163161
assertThat(cert.getValidity()).isNotNull();
164-
assertThat(cert.getValidity().getStatus()).isSameAs(Status.WILL_EXPIRE_SOON);
162+
assertThat(cert.getValidity().getStatus()).isEqualTo(Status.WILL_EXPIRE_SOON);
165163
assertThat(cert.getValidity().getMessage()).startsWith("Certificate will expire within threshold");
166164
}
167165

168166
@Test
169-
@WithPackageResources({ "test.p12", "test-not-yet-valid.p12", "test-expired.p12" })
170-
void multipleBundlesShouldProvideSslInfo(@TempDir Path tempDir) throws IOException, InterruptedException {
171-
Path keyStore = createKeyStore(tempDir);
167+
@WithPackageResources({ "test.p12", "test-not-yet-valid.p12", "test-expired.p12", "will-expire-soon.p12" })
168+
void multipleBundlesShouldProvideSslInfo() {
172169
SslInfo sslInfo = createSslInfo("classpath:test.p12", "classpath:test-not-yet-valid.p12",
173-
"classpath:test-expired.p12", keyStore.toString());
170+
"classpath:test-expired.p12", "classpath:will-expire-soon.p12");
174171
assertThat(sslInfo.getBundles()).hasSize(4);
175172
assertThat(sslInfo.getBundles()).allSatisfy((bundle) -> assertThat(bundle.getName()).startsWith("test-"));
176173
List<CertificateInfo> certs = sslInfo.getBundles()
@@ -188,29 +185,29 @@ void multipleBundlesShouldProvideSslInfo(@TempDir Path tempDir) throws IOExcepti
188185
assertThat(cert.getValidity()).isNotNull();
189186
});
190187
assertThat(certs).anySatisfy((cert) -> {
191-
assertThat(cert.getValidityStarts()).isInThePast();
192-
assertThat(cert.getValidityEnds()).isInTheFuture();
188+
assertThat(cert.getValidityStarts()).isBefore(CLOCK.instant());
189+
assertThat(cert.getValidityEnds()).isAfter(CLOCK.instant());
193190
assertThat(cert.getValidity()).isNotNull();
194191
assertThat(cert.getValidity().getStatus()).isSameAs(Status.VALID);
195192
assertThat(cert.getValidity().getMessage()).isNull();
196193
});
197194
assertThat(certs).satisfiesOnlyOnce((cert) -> {
198-
assertThat(cert.getValidityStarts()).isInTheFuture();
199-
assertThat(cert.getValidityEnds()).isInTheFuture();
195+
assertThat(cert.getValidityStarts()).isAfter(CLOCK.instant());
196+
assertThat(cert.getValidityEnds()).isAfter(CLOCK.instant());
200197
assertThat(cert.getValidity()).isNotNull();
201198
assertThat(cert.getValidity().getStatus()).isSameAs(Status.NOT_YET_VALID);
202199
assertThat(cert.getValidity().getMessage()).startsWith("Not valid before");
203200
});
204201
assertThat(certs).satisfiesOnlyOnce((cert) -> {
205-
assertThat(cert.getValidityStarts()).isInThePast();
206-
assertThat(cert.getValidityEnds()).isInThePast();
202+
assertThat(cert.getValidityStarts()).isBefore(CLOCK.instant());
203+
assertThat(cert.getValidityEnds()).isBefore(CLOCK.instant());
207204
assertThat(cert.getValidity()).isNotNull();
208205
assertThat(cert.getValidity().getStatus()).isSameAs(Status.EXPIRED);
209206
assertThat(cert.getValidity().getMessage()).startsWith("Not valid after");
210207
});
211208
assertThat(certs).satisfiesOnlyOnce((cert) -> {
212-
assertThat(cert.getValidityStarts()).isInThePast();
213-
assertThat(cert.getValidityEnds()).isInTheFuture();
209+
assertThat(cert.getValidityStarts()).isBefore(CLOCK.instant());
210+
assertThat(cert.getValidityEnds()).isAfter(CLOCK.instant());
214211
assertThat(cert.getValidity()).isNotNull();
215212
assertThat(cert.getValidity().getStatus()).isSameAs(Status.WILL_EXPIRE_SOON);
216213
assertThat(cert.getValidity().getMessage()).startsWith("Certificate will expire within threshold");
@@ -221,7 +218,7 @@ void multipleBundlesShouldProvideSslInfo(@TempDir Path tempDir) throws IOExcepti
221218
void nullKeyStore() {
222219
DefaultSslBundleRegistry sslBundleRegistry = new DefaultSslBundleRegistry();
223220
sslBundleRegistry.registerBundle("test", SslBundle.of(SslStoreBundle.NONE, SslBundleKey.NONE));
224-
SslInfo sslInfo = new SslInfo(sslBundleRegistry, Duration.ofDays(7));
221+
SslInfo sslInfo = new SslInfo(sslBundleRegistry, Duration.ofDays(7), CLOCK);
225222
assertThat(sslInfo.getBundles()).hasSize(1);
226223
assertThat(sslInfo.getBundles().get(0).getCertificateChains()).isEmpty();
227224
}
@@ -233,41 +230,7 @@ private SslInfo createSslInfo(String... locations) {
233230
SslStoreBundle sslStoreBundle = new JksSslStoreBundle(keyStoreDetails, null);
234231
sslBundleRegistry.registerBundle("test-%d".formatted(i), SslBundle.of(sslStoreBundle));
235232
}
236-
return new SslInfo(sslBundleRegistry, Duration.ofDays(7));
237-
}
238-
239-
private Path createKeyStore(Path directory) throws IOException, InterruptedException {
240-
Path keyStore = directory.resolve("test.p12");
241-
Process process = createProcessBuilder(keyStore).start();
242-
int exitCode = process.waitFor();
243-
if (exitCode != 0) {
244-
try (BufferedReader reader = new BufferedReader(
245-
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
246-
String out = reader.lines().collect(Collectors.joining("\n"));
247-
throw new RuntimeException("Unexpected exit code from keytool: %d\n%s".formatted(exitCode, out));
248-
}
249-
}
250-
return keyStore;
251-
}
252-
253-
private ProcessBuilder createProcessBuilder(Path keystore) {
254-
// @formatter:off
255-
ProcessBuilder processBuilder = new ProcessBuilder(
256-
"keytool",
257-
"-genkeypair",
258-
"-storetype", "PKCS12",
259-
"-alias", "spring-boot",
260-
"-keyalg", "RSA",
261-
"-storepass", "secret",
262-
"-keypass", "secret",
263-
"-keystore", keystore.toString(),
264-
"-dname", "CN=localhost,OU=Spring,O=VMware,L=Palo Alto,ST=California,C=US",
265-
"-validity", "1",
266-
"-ext", "SAN=DNS:localhost,IP:::1,IP:127.0.0.1"
267-
);
268-
// @formatter:on
269-
processBuilder.redirectErrorStream(true);
270-
return processBuilder;
233+
return new SslInfo(sslBundleRegistry, Duration.ofDays(7), CLOCK);
271234
}
272235

273236
}

0 commit comments

Comments
 (0)