From e661f6ec1bd0878fbc7d8bd6e80e928764e40df5 Mon Sep 17 00:00:00 2001 From: Moritz Halbritter Date: Mon, 23 Jun 2025 09:12:55 +0200 Subject: [PATCH] Use fixed clock when testing SslInfo Closes gh-45573 --- .../springframework/boot/info/SslInfo.java | 49 ++++++--- .../boot/info/SslInfoTests.java | 103 ++++++------------ .../boot/info/will-expire-soon.p12 | Bin 0 -> 2802 bytes 3 files changed, 64 insertions(+), 88 deletions(-) create mode 100644 spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/info/will-expire-soon.p12 diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java index e4fdc499a44..93013a690e4 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java @@ -19,9 +19,8 @@ package org.springframework.boot.info; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.cert.Certificate; -import java.security.cert.CertificateExpiredException; -import java.security.cert.CertificateNotYetValidException; import java.security.cert.X509Certificate; +import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.Arrays; @@ -41,6 +40,7 @@ import org.springframework.util.ObjectUtils; * Information about the certificates that the application uses. * * @author Jonatan Ivanov + * @author Moritz Halbritter * @since 3.4.0 */ public class SslInfo { @@ -49,9 +49,16 @@ public class SslInfo { private final Duration certificateValidityWarningThreshold; + private final Clock clock; + public SslInfo(SslBundles sslBundles, Duration certificateValidityWarningThreshold) { + this(sslBundles, certificateValidityWarningThreshold, Clock.systemDefaultZone()); + } + + SslInfo(SslBundles sslBundles, Duration certificateValidityWarningThreshold, Clock clock) { this.sslBundles = sslBundles; this.certificateValidityWarningThreshold = certificateValidityWarningThreshold; + this.clock = clock; } public List getBundles() { @@ -179,25 +186,31 @@ public class SslInfo { Instant starts = getValidityStarts(); Instant ends = getValidityEnds(); Duration threshold = SslInfo.this.certificateValidityWarningThreshold; - try { - certificate.checkValidity(); - return (!isExpiringSoon(certificate, threshold)) ? CertificateValidityInfo.VALID - : new CertificateValidityInfo(Status.WILL_EXPIRE_SOON, - "Certificate will expire within threshold (%s) at %s", threshold, ends); - } - catch (CertificateNotYetValidException ex) { - return new CertificateValidityInfo(Status.NOT_YET_VALID, "Not valid before %s", starts); - } - catch (CertificateExpiredException ex) { - return new CertificateValidityInfo(Status.EXPIRED, "Not valid after %s", ends); - } + CertificateValidityInfo.Status validity = checkValidity(starts, ends, threshold); + return switch (validity) { + case VALID -> CertificateValidityInfo.VALID; + case EXPIRED -> new CertificateValidityInfo(Status.EXPIRED, "Not valid after %s", ends); + case NOT_YET_VALID -> + new CertificateValidityInfo(Status.NOT_YET_VALID, "Not valid before %s", starts); + case WILL_EXPIRE_SOON -> new CertificateValidityInfo(Status.WILL_EXPIRE_SOON, + "Certificate will expire within threshold (%s) at %s", threshold, ends); + }; }); } - private boolean isExpiringSoon(X509Certificate certificate, Duration threshold) { - Instant shouldBeValidAt = Instant.now().plus(threshold); - Instant expiresAt = certificate.getNotAfter().toInstant(); - return shouldBeValidAt.isAfter(expiresAt); + private CertificateValidityInfo.Status checkValidity(Instant starts, Instant ends, Duration threshold) { + Instant now = SslInfo.this.clock.instant(); + if (now.isBefore(starts)) { + return CertificateValidityInfo.Status.NOT_YET_VALID; + } + if (now.isAfter(ends)) { + return CertificateValidityInfo.Status.EXPIRED; + } + Instant shouldBeValidAt = now.plus(threshold); + if (shouldBeValidAt.isAfter(ends)) { + return CertificateValidityInfo.Status.WILL_EXPIRE_SOON; + } + return CertificateValidityInfo.Status.VALID; } private R extract(Function valueExtractor, Function resultExtractor) { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java index 0bd87c0ca47..a3c5dc2cb99 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java @@ -16,17 +16,13 @@ package org.springframework.boot.info; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; +import java.time.Clock; import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; import java.util.List; -import java.util.stream.Collectors; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; import org.springframework.boot.info.SslInfo.BundleInfo; import org.springframework.boot.info.SslInfo.CertificateChainInfo; @@ -46,9 +42,12 @@ import static org.assertj.core.api.Assertions.assertThat; * Tests for {@link SslInfo}. * * @author Jonatan Ivanov + * @author Moritz Halbritter */ class SslInfoTests { + private static final Clock CLOCK = Clock.fixed(Instant.parse("2025-06-18T13:00:00Z"), ZoneId.of("UTC")); + @Test @WithPackageResources("test.p12") void validCertificatesShouldProvideSslInfo() { @@ -71,8 +70,8 @@ class SslInfoTests { assertThat(cert1.getSerialNumber()).isNotEmpty(); assertThat(cert1.getVersion()).isEqualTo("V3"); assertThat(cert1.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA"); - assertThat(cert1.getValidityStarts()).isInThePast(); - assertThat(cert1.getValidityEnds()).isInTheFuture(); + assertThat(cert1.getValidityStarts()).isBefore(CLOCK.instant()); + assertThat(cert1.getValidityEnds()).isAfter(CLOCK.instant()); assertThat(cert1.getValidity()).isNotNull(); assertThat(cert1.getValidity().getStatus()).isSameAs(Status.VALID); assertThat(cert1.getValidity().getMessage()).isNull(); @@ -82,8 +81,8 @@ class SslInfoTests { assertThat(cert2.getSerialNumber()).isNotEmpty(); assertThat(cert2.getVersion()).isEqualTo("V3"); assertThat(cert2.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA"); - assertThat(cert2.getValidityStarts()).isInThePast(); - assertThat(cert2.getValidityEnds()).isInTheFuture(); + assertThat(cert2.getValidityStarts()).isBefore(CLOCK.instant()); + assertThat(cert2.getValidityEnds()).isAfter(CLOCK.instant()); assertThat(cert2.getValidity()).isNotNull(); assertThat(cert2.getValidity().getStatus()).isSameAs(Status.VALID); assertThat(cert2.getValidity().getMessage()).isNull(); @@ -107,8 +106,8 @@ class SslInfoTests { assertThat(cert.getSerialNumber()).isNotEmpty(); assertThat(cert.getVersion()).isEqualTo("V3"); assertThat(cert.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA"); - assertThat(cert.getValidityStarts()).isInTheFuture(); - assertThat(cert.getValidityEnds()).isInTheFuture(); + assertThat(cert.getValidityStarts()).isAfter(CLOCK.instant()); + assertThat(cert.getValidityEnds()).isAfter(CLOCK.instant()); assertThat(cert.getValidity()).isNotNull(); assertThat(cert.getValidity().getStatus()).isSameAs(Status.NOT_YET_VALID); assertThat(cert.getValidity().getMessage()).startsWith("Not valid before"); @@ -132,18 +131,17 @@ class SslInfoTests { assertThat(cert.getSerialNumber()).isNotEmpty(); assertThat(cert.getVersion()).isEqualTo("V3"); assertThat(cert.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA"); - assertThat(cert.getValidityStarts()).isInThePast(); - assertThat(cert.getValidityEnds()).isInThePast(); + assertThat(cert.getValidityStarts()).isBefore(CLOCK.instant()); + assertThat(cert.getValidityEnds()).isBefore(CLOCK.instant()); assertThat(cert.getValidity()).isNotNull(); assertThat(cert.getValidity().getStatus()).isSameAs(Status.EXPIRED); assertThat(cert.getValidity().getMessage()).startsWith("Not valid after"); } @Test - void soonToBeExpiredCertificateShouldProvideSslInfo(@TempDir Path tempDir) - throws IOException, InterruptedException { - Path keyStore = createKeyStore(tempDir); - SslInfo sslInfo = createSslInfo(keyStore.toString()); + @WithPackageResources({ "will-expire-soon.p12" }) + void soonToBeExpiredCertificateShouldProvideSslInfo() { + SslInfo sslInfo = createSslInfo("classpath:will-expire-soon.p12"); assertThat(sslInfo.getBundles()).hasSize(1); BundleInfo bundle = sslInfo.getBundles().get(0); assertThat(bundle.getName()).isEqualTo("test-0"); @@ -158,19 +156,18 @@ class SslInfoTests { assertThat(cert.getSerialNumber()).isNotEmpty(); assertThat(cert.getVersion()).isEqualTo("V3"); assertThat(cert.getSignatureAlgorithmName()).isNotEmpty(); - assertThat(cert.getValidityStarts()).isInThePast(); - assertThat(cert.getValidityEnds()).isInTheFuture(); + assertThat(cert.getValidityStarts()).isBefore(CLOCK.instant()); + assertThat(cert.getValidityEnds()).isAfter(CLOCK.instant()); assertThat(cert.getValidity()).isNotNull(); - assertThat(cert.getValidity().getStatus()).isSameAs(Status.WILL_EXPIRE_SOON); + assertThat(cert.getValidity().getStatus()).isEqualTo(Status.WILL_EXPIRE_SOON); assertThat(cert.getValidity().getMessage()).startsWith("Certificate will expire within threshold"); } @Test - @WithPackageResources({ "test.p12", "test-not-yet-valid.p12", "test-expired.p12" }) - void multipleBundlesShouldProvideSslInfo(@TempDir Path tempDir) throws IOException, InterruptedException { - Path keyStore = createKeyStore(tempDir); + @WithPackageResources({ "test.p12", "test-not-yet-valid.p12", "test-expired.p12", "will-expire-soon.p12" }) + void multipleBundlesShouldProvideSslInfo() { SslInfo sslInfo = createSslInfo("classpath:test.p12", "classpath:test-not-yet-valid.p12", - "classpath:test-expired.p12", keyStore.toString()); + "classpath:test-expired.p12", "classpath:will-expire-soon.p12"); assertThat(sslInfo.getBundles()).hasSize(4); assertThat(sslInfo.getBundles()).allSatisfy((bundle) -> assertThat(bundle.getName()).startsWith("test-")); List certs = sslInfo.getBundles() @@ -188,29 +185,29 @@ class SslInfoTests { assertThat(cert.getValidity()).isNotNull(); }); assertThat(certs).anySatisfy((cert) -> { - assertThat(cert.getValidityStarts()).isInThePast(); - assertThat(cert.getValidityEnds()).isInTheFuture(); + assertThat(cert.getValidityStarts()).isBefore(CLOCK.instant()); + assertThat(cert.getValidityEnds()).isAfter(CLOCK.instant()); assertThat(cert.getValidity()).isNotNull(); assertThat(cert.getValidity().getStatus()).isSameAs(Status.VALID); assertThat(cert.getValidity().getMessage()).isNull(); }); assertThat(certs).satisfiesOnlyOnce((cert) -> { - assertThat(cert.getValidityStarts()).isInTheFuture(); - assertThat(cert.getValidityEnds()).isInTheFuture(); + assertThat(cert.getValidityStarts()).isAfter(CLOCK.instant()); + assertThat(cert.getValidityEnds()).isAfter(CLOCK.instant()); assertThat(cert.getValidity()).isNotNull(); assertThat(cert.getValidity().getStatus()).isSameAs(Status.NOT_YET_VALID); assertThat(cert.getValidity().getMessage()).startsWith("Not valid before"); }); assertThat(certs).satisfiesOnlyOnce((cert) -> { - assertThat(cert.getValidityStarts()).isInThePast(); - assertThat(cert.getValidityEnds()).isInThePast(); + assertThat(cert.getValidityStarts()).isBefore(CLOCK.instant()); + assertThat(cert.getValidityEnds()).isBefore(CLOCK.instant()); assertThat(cert.getValidity()).isNotNull(); assertThat(cert.getValidity().getStatus()).isSameAs(Status.EXPIRED); assertThat(cert.getValidity().getMessage()).startsWith("Not valid after"); }); assertThat(certs).satisfiesOnlyOnce((cert) -> { - assertThat(cert.getValidityStarts()).isInThePast(); - assertThat(cert.getValidityEnds()).isInTheFuture(); + assertThat(cert.getValidityStarts()).isBefore(CLOCK.instant()); + assertThat(cert.getValidityEnds()).isAfter(CLOCK.instant()); assertThat(cert.getValidity()).isNotNull(); assertThat(cert.getValidity().getStatus()).isSameAs(Status.WILL_EXPIRE_SOON); assertThat(cert.getValidity().getMessage()).startsWith("Certificate will expire within threshold"); @@ -221,7 +218,7 @@ class SslInfoTests { void nullKeyStore() { DefaultSslBundleRegistry sslBundleRegistry = new DefaultSslBundleRegistry(); sslBundleRegistry.registerBundle("test", SslBundle.of(SslStoreBundle.NONE, SslBundleKey.NONE)); - SslInfo sslInfo = new SslInfo(sslBundleRegistry, Duration.ofDays(7)); + SslInfo sslInfo = new SslInfo(sslBundleRegistry, Duration.ofDays(7), CLOCK); assertThat(sslInfo.getBundles()).hasSize(1); assertThat(sslInfo.getBundles().get(0).getCertificateChains()).isEmpty(); } @@ -233,41 +230,7 @@ class SslInfoTests { SslStoreBundle sslStoreBundle = new JksSslStoreBundle(keyStoreDetails, null); sslBundleRegistry.registerBundle("test-%d".formatted(i), SslBundle.of(sslStoreBundle)); } - return new SslInfo(sslBundleRegistry, Duration.ofDays(7)); - } - - private Path createKeyStore(Path directory) throws IOException, InterruptedException { - Path keyStore = directory.resolve("test.p12"); - Process process = createProcessBuilder(keyStore).start(); - int exitCode = process.waitFor(); - if (exitCode != 0) { - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { - String out = reader.lines().collect(Collectors.joining("\n")); - throw new RuntimeException("Unexpected exit code from keytool: %d\n%s".formatted(exitCode, out)); - } - } - return keyStore; - } - - private ProcessBuilder createProcessBuilder(Path keystore) { - // @formatter:off - ProcessBuilder processBuilder = new ProcessBuilder( - "keytool", - "-genkeypair", - "-storetype", "PKCS12", - "-alias", "spring-boot", - "-keyalg", "RSA", - "-storepass", "secret", - "-keypass", "secret", - "-keystore", keystore.toString(), - "-dname", "CN=localhost,OU=Spring,O=VMware,L=Palo Alto,ST=California,C=US", - "-validity", "1", - "-ext", "SAN=DNS:localhost,IP:::1,IP:127.0.0.1" - ); - // @formatter:on - processBuilder.redirectErrorStream(true); - return processBuilder; + return new SslInfo(sslBundleRegistry, Duration.ofDays(7), CLOCK); } } diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/info/will-expire-soon.p12 b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/info/will-expire-soon.p12 new file mode 100644 index 0000000000000000000000000000000000000000..bd6c72e01a3cad15cb63fd5c3b11e0bab747c25b GIT binary patch literal 2802 zcma)8cQhM{8jnb<*pwKlJ!&-wQ6m~k?b%X0R@8{tN|iK-)K==*d$d&CqDG7A)mx&p zT--_(tx>ar+NxgPdGGX|_t$&pJKy=v_Z#1zzw^O~Y@0v^MmUkJfQeOsWI{UNU|?pb zAhO*95!sS|!gt|BaOZzf;71@Lxb`Pp`Lj!zApdc(voQcGh~UdVL0vfZmjlcJcYq zh(Q6wZIt)5>o&R8@JujyX$a(j1`$D{%zijn$KXy5v(+70T%OyYgiQoQqqf<*we>wG z9^<qV_dg?u1NYwE&f{Qbwk zInwjo4TG>jW!S5d*+;4OW2WTI@9nnl?<1>24MePh<_dBg`ogl1y##UpBwP66!4 zuQy(Vbl4MIhc==dDOTUjTunG^gcs7xC7*{kZs1iN?cTyBIk}Wf^=xDAl{glDB%BXo z6Z$N@#aknvr+h7Z?Dor9x!B;mocx_qnf(z5=DenF{wnW1j7P;^zO{;G5jfn8Tc?k` z>uw-)px$kdrXjXvXZ25-vsXxlJIf;)=pB$bMGp3lE{OW_{sn8j!?{z@dFg?VX?OlI zm#KC2Zc1e`rFT--?bsuw8B4Hd5gO>Mt%O?#jH#g_YFGWHkJGi=7C^l%MC zJ`r;JK~x~{alUPQrf)21D!BflIk{8uV#U&UleK7#hr5AYlSj8(8HP;ZWrNT{5`!K8 zcta`n)^}ZK=^06J8?G)w6kF&d26;`T)^Y| zu4;Tz%@e5rBRNRcXyl9U_BW{5Q=fi2q(xhgjP{f&8I)MHie8)v+c=V0M9Vi~Qt>M1 za1gRAj1K$%m zw%6wvWp61)E_(0JDfwlh4E;HE>(#XoxAA@K5u=jKd9~b5TgDBUnkyN>rv?-&#xUED zdRmLB6(H(Nar*wjL5}&OThgv7b>m&f_bl#uhj)>~U5zR|`!s=rc>P{NO=zh_w4$x6b2>8iWD|-dCpb8p$o03< zI0+O#NEH5|7=L!W{18I>i%3d}#F*C`ZD8YvXXgaClhr5A9Xcq@l`9W-^JtrXqeT9| z3dWz}qIbM?`ilIxbVZV;mT>iJvpMCDKgflQ8sDf)qD8EJa+yoHqte91onao^+>033 zdWMMgcU01XnTr93>`bUfi|2gz>HLPcTca z6OP{)rD_J3`4yL}{HoCN0)TKp2p|mL4+sYM0h9q=0Q}F2fJ^)>JEzJI;=SgN^A%S` zYQWVHNG*hh1_B8uf?oa-F*8*VK`lQa3XlQtbJYLMF#NB;UNsACg$=;0F5Lj8DyNAV zy3KSf|387v-!s>6w`)9p)7*+os@#uk}@q+gYJ1dU~CE!lM3EHahB0H)#(zQm&)H;41&Sz zgf=#T0&IgUS1g=&S)ZrM<-4u0PoOZ{{W)0()ZMGPn1;p4nRw@9VNFmJP4Gg{(r}(h z{{j4)*SD;saTdH+ah9 zleqV0A1R`?g^Vdc2%*>1IDWz_<4)yX4H`JSlX`B=0o(21a zzownn>1Uzp%EITJ4xDM$B^}v2M4;Y8=_-s~m*pPKZk->YrtcrGi9as}J)RxRxz*jE z>sy*?*|}X+UDzc(r}a9xY-|~mSVtC2i-+jN3Pz#m0yJ;Z(*80bDVO#_A7NEkA#OTA z>QS66?x1S%SU$wqj%J>i%Fp`um5m^e#tV*xUxuVN|Sw`{v#xvFO&PnQ|GpPPpTya z2Fe4=bC`Oj_7=a0Ca7j~;%;(AxZf)0k9RL1fnUv`?xpyg<3HAi`fpPh?qb~RdkrA>SypedOUD?G)Hr4igD@VbjU{7m!l%>}a zu=Ram7O1d^qnhlQTfwA4q_CnRqX5rzZgDkmj&(iAP+9G=#c;Z`9qoWw+Xxs@U^(mm zjqmG@LErR3T(n-ZltaSvbgD==MusF)$ug2G=B&PeGTWw`a~(Z| zp!(|ya7(x