LagAwareStrategySslIT.java

package redis.clients.jedis.scenario;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Tags;
import org.junit.jupiter.api.Test;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
import redis.clients.jedis.DefaultRedisCredentials;
import redis.clients.jedis.Endpoint;
import redis.clients.jedis.EndpointConfig;
import redis.clients.jedis.Endpoints;
import redis.clients.jedis.RedisCredentials;
import redis.clients.jedis.SslOptions;
import redis.clients.jedis.SslVerifyMode;
import redis.clients.jedis.exceptions.JedisException;
import redis.clients.jedis.mcf.HealthStatus;
import redis.clients.jedis.mcf.LagAwareStrategy;
import redis.clients.jedis.util.TlsUtil;

import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLSocketFactory;
import java.nio.file.Path;
import java.security.cert.X509Certificate;
import java.util.function.Supplier;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.fail;

/**
 * Test class demonstrating SSL configuration for LagAwareStrategy
 */
@Tags({ @Tag("scenario") })
public class LagAwareStrategySslIT {
  private static EndpointConfig crdb;
  private static Endpoint restEndpoint;
  private static Supplier<RedisCredentials> credentialsSupplier;
  private static final char[] trustStorePassword = "changeit".toCharArray();

  // truststore with certificate from REST API endpoint
  private static final String trustStorePfx = LagAwareStrategySslIT.class.getSimpleName();
  private static final Path crdbTrustStore = TlsUtil.testTruststorePath(trustStorePfx + "-crdb");
  // empty truststore to test untrusted cert
  private static final Path dummyTrustStore = TlsUtil.testTruststorePath(trustStorePfx + "-dummy");

  private SSLSocketFactory origSslSocketFactory;

  @BeforeAll
  public static void beforeClass() {

    crdb = Endpoints.getRedisEndpoint("re-active-active");
    restEndpoint = RestEndpointUtil.getRestAPIEndpoint(crdb);
    credentialsSupplier = () -> new DefaultRedisCredentials("test@redis.com", "test123");
    try {
      TlsUtil.createEmptyTruststore(dummyTrustStore, trustStorePassword);
      createTrustedTruststore(crdbTrustStore, restEndpoint.getHost(), restEndpoint.getPort(),
        trustStorePassword);
    } catch (Exception e) {
      fail("Failed to create test truststore", e);
    }
  }

  @BeforeEach
  public void enforceEmptyDefaultTrustStore() {
    TlsUtil.setCustomTrustStore(dummyTrustStore, new String(trustStorePassword));
    origSslSocketFactory = HttpsURLConnection.getDefaultSSLSocketFactory();
  }

  @AfterEach
  public void restoreDefaultTrustStore() {
    TlsUtil.restoreOriginalTrustStore();
    HttpsURLConnection.setDefaultSSLSocketFactory(origSslSocketFactory);
  }

  @ParameterizedTest(name = "SSL mode {0} should be HEALTHY")
  @EnumSource(value = SslVerifyMode.class, names = { "FULL", "CA", "INSECURE" })
  public void healthyWhenUsingCustomTruststore(SslVerifyMode sslVerifyMode) throws Exception {
    // Create SSL options with custom truststore
    SslOptions sslOptions = SslOptions.builder()
        .truststore(crdbTrustStore.toFile(), trustStorePassword).sslVerifyMode(sslVerifyMode)
        .build();

    // Create LagAwareStrategy config with SSL support using builder
    LagAwareStrategy.Config config = LagAwareStrategy.Config
        .builder(restEndpoint, credentialsSupplier).sslOptions(sslOptions).build();

    try (LagAwareStrategy lagAwareStrategy = new LagAwareStrategy(config)) {
      assertEquals(HealthStatus.HEALTHY, lagAwareStrategy.doHealthCheck(crdb.getHostAndPort()));
    }
  }

  @Test
  void usingDefaultTruststoreWithUntrustedCertificateInsecure() {
    // Create SSL options without specifying truststore
    SslOptions sslOptions = SslOptions.builder().sslVerifyMode(SslVerifyMode.INSECURE).build();

    // Create LagAwareStrategy config with SSL support using builder
    LagAwareStrategy.Config config = LagAwareStrategy.Config
        .builder(restEndpoint, credentialsSupplier).sslOptions(sslOptions).build();

    try (LagAwareStrategy lagAwareStrategy = new LagAwareStrategy(config)) {
      assertEquals(HealthStatus.HEALTHY, lagAwareStrategy.doHealthCheck(crdb.getHostAndPort()));
    }
  }

  @ParameterizedTest(name = "SSL mode {0} should result in {1}")
  @CsvSource({ "FULL, UNHEALTHY", "CA, UNHEALTHY" })
  void usingDefaultTruststoreWithUntrustedCertificateThrowsException(SslVerifyMode sslVerifyMode,
      HealthStatus expected) {
    // Create SSL options without specifying truststore
    SslOptions sslOptions = SslOptions.builder().sslVerifyMode(sslVerifyMode).build();

    // Create LagAwareStrategy config with SSL support using builder
    LagAwareStrategy.Config config = LagAwareStrategy.Config
        .builder(restEndpoint, credentialsSupplier).sslOptions(sslOptions).build();

    try (LagAwareStrategy lagAwareStrategy = new LagAwareStrategy(config)) {
      JedisException ex = assertThrows(JedisException.class, () -> {
        lagAwareStrategy.doHealthCheck(crdb.getHostAndPort());
      });
    }
  }

  @ParameterizedTest(name = "SSL mode {0} should result in {1}")
  @CsvSource({ "FULL, HEALTHY", "CA, HEALTHY", "INSECURE, HEALTHY" })
  void healthyWhenUsingDefaultTruststoreWithTrustedCertificate(SslVerifyMode sslVerifyMode,
      HealthStatus expected) {
    // Create SSL options without specifying truststore
    SslOptions sslOptions = SslOptions.builder().sslVerifyMode(sslVerifyMode).build();

    // Create LagAwareStrategy config with SSL support using builder
    LagAwareStrategy.Config config = LagAwareStrategy.Config
        .builder(restEndpoint, credentialsSupplier).sslOptions(sslOptions).build();

    // Verify configuration - trusted cert should pass
    // Default SSL context is used if no user defined trust store is configured
    // Configure default SSL context to trust our custom CA
    // and reinitialize default SSL context for HttpsURLConnection
    TlsUtil.setCustomTrustStore(crdbTrustStore, new String(trustStorePassword));
    SSLSocketFactory orig = HttpsURLConnection.getDefaultSSLSocketFactory();
    try (LagAwareStrategy lagAwareStrategy = new LagAwareStrategy(config)) {
      HttpsURLConnection
          .setDefaultSSLSocketFactory((SSLSocketFactory) SSLSocketFactory.getDefault());
      assertEquals(expected, lagAwareStrategy.doHealthCheck(crdb.getHostAndPort()));
    } finally {
      TlsUtil.restoreOriginalTrustStore();
      HttpsURLConnection.setDefaultSSLSocketFactory(orig);
    }
  }

  @Test
  public void fallbackToDefaultSslContextWhenSslOptionsAreNull() {
    // Test that SSL options can be null
    // If SSL options are null, we should fallback to default SSL context
    LagAwareStrategy.Config config = LagAwareStrategy.Config
        .builder(restEndpoint, credentialsSupplier).build();

    // Verify configuration - untrusted cert should fail
    TlsUtil.setCustomTrustStore(dummyTrustStore, new String(trustStorePassword));
    SSLSocketFactory orig = HttpsURLConnection.getDefaultSSLSocketFactory();
    try (LagAwareStrategy lagAwareStrategy = new LagAwareStrategy(config)) {
      assertThrows(JedisException.class, () -> {
        lagAwareStrategy.doHealthCheck(crdb.getHostAndPort());
      });
    } finally {
      TlsUtil.restoreOriginalTrustStore();
      HttpsURLConnection.setDefaultSSLSocketFactory(orig);
    }

    // Verify configuration - trusted cert should pass
    // Default SSL context is used if no SSL options are provided
    // Configure default SSL context to trust our custom CA
    // and reinitialize default SSL context for HttpsURLConnection
    TlsUtil.setCustomTrustStore(crdbTrustStore, new String(trustStorePassword));
    orig = HttpsURLConnection.getDefaultSSLSocketFactory();
    try (LagAwareStrategy lagAwareStrategy = new LagAwareStrategy(config)) {
      HttpsURLConnection
          .setDefaultSSLSocketFactory((SSLSocketFactory) SSLSocketFactory.getDefault());
      assertEquals(HealthStatus.HEALTHY, lagAwareStrategy.doHealthCheck(crdb.getHostAndPort()));
    } finally {
      TlsUtil.restoreOriginalTrustStore();
      HttpsURLConnection.setDefaultSSLSocketFactory(orig);
    }
  }

  /**
   * Downloads the server certificate from a host and pins it into a truststore.
   * @param truststore path where the truststore will be created
   * @param host host to connect to (e.g., redis.example.com)
   * @param port TLS port (e.g., 9443)
   * @param password password for the truststore
   */
  static void createTrustedTruststore(Path truststore, String host, int port, char[] password)
      throws Exception {
    X509Certificate[] serverCerts = TlsUtil.captureServerChain(host, port);

    // Pin server certificate (first in chain)
    TlsUtil.createAndSaveTruststore(new X509Certificate[] { serverCerts[0] }, truststore,
      new String(password));
  }
}