mirror of
https://github.com/spring-projects/spring-boot.git
synced 2026-09-28 13:09:09 +00:00
Use fixed clock when testing SslInfo
Closes gh-45573
This commit is contained in:
+31
-18
@@ -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<BundleInfo> 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 <V, R> R extract(Function<X509Certificate, V> valueExtractor, Function<V, R> resultExtractor) {
|
||||
|
||||
+33
-70
@@ -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<CertificateInfo> 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
Reference in New Issue
Block a user