Browse Source

Use fixed clock when testing SslInfo

Closes gh-45573
pull/46211/head
Moritz Halbritter 7 months ago
parent
commit
e661f6ec1b
  1. 49
      spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java
  2. 103
      spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java
  3. BIN
      spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/info/will-expire-soon.p12

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

@ -19,9 +19,8 @@ package org.springframework.boot.info; @@ -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; @@ -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 { @@ -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<BundleInfo> getBundles() {
@ -179,25 +186,31 @@ public class SslInfo { @@ -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 <V, R> R extract(Function<X509Certificate, V> valueExtractor, Function<V, R> resultExtractor) {

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

@ -16,17 +16,13 @@ @@ -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; @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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<CertificateInfo> certs = sslInfo.getBundles()
@ -188,29 +185,29 @@ class SslInfoTests { @@ -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 { @@ -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 { @@ -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);
}
}

BIN
spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/info/will-expire-soon.p12

Binary file not shown.
Loading…
Cancel
Save