MaintNotificationsScenarioBase.java

package redis.clients.jedis.scenario;

import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.cert.X509Certificate;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.redis.test.fi.Database;
import com.redis.test.fi.Scenario;

import org.apache.commons.pool2.impl.GenericObjectPoolConfig;

import org.junit.jupiter.api.AfterEach;

import redis.clients.jedis.Connection;
import redis.clients.jedis.DefaultJedisClientConfig;
import redis.clients.jedis.MaintenanceNotificationsConfig;
import redis.clients.jedis.RedisClient;
import redis.clients.jedis.SslOptions;
import redis.clients.jedis.util.TlsUtil;

/**
 * Scenario tests that run one standalone {@link RedisClient} against the deployed database. A
 * subclass deploys the database and opens the client through {@link #openClient}; the client config
 * starts from the database's credentials (plus pinned TLS trust for a TLS database) and the pool
 * from its defaults, and {@link #customiseClientConfig} / {@link #customiseClientPoolConfig} add a
 * subclass's settings. The client is owned here and closed on teardown before the database is
 * deleted.
 */
abstract class MaintNotificationsScenarioBase extends FaultInjectorScenarioBase {

  private static final Logger logger = LoggerFactory
      .getLogger(MaintNotificationsScenarioBase.class);

  static final String TRUSTSTORE_PASSWORD = "changeit";

  RedisClient client;
  Path serverTruststore;

  /** Opens the client under test and takes ownership of it. */
  RedisClient openClient(Database database, MaintenanceNotificationsConfig maintenance) {
    DefaultJedisClientConfig.Builder config = database.clientConfig();
    if (database.tls()) {
      serverTruststore = createServerTruststore(database.endpoint());
      config.sslOptions(sslOptionsFor(serverTruststore));
    }
    customiseClientConfig(config);
    GenericObjectPoolConfig<Connection> poolConfig = new GenericObjectPoolConfig<>();
    customiseClientPoolConfig(poolConfig);
    client = RedisClient.builder().hostAndPort(database.host(), database.port())
        .clientConfig(config.build()).poolConfig(poolConfig).maintenanceNotifications(maintenance)
        .build();
    return client;
  }

  /**
   * Adds client settings on top of the credentials and TLS trust. The default adds nothing: the
   * client is what an application gets without tuning.
   */
  void customiseClientConfig(DefaultJedisClientConfig.Builder config) {
  }

  /** Adds pool settings on top of the defaults. */
  void customiseClientPoolConfig(GenericObjectPoolConfig<Connection> poolConfig) {
  }

  /**
   * Bootstraps client trust for an endpoint whose CA is not available to the test: downloads the
   * certificate chain the server presents and saves a truststore containing only that chain, so the
   * client trusts exactly this server and nothing else. The store is a temp file under the test
   * work folder, deleted when the test completes.
   */
  private static Path createServerTruststore(URI endpoint) {
    try {
      X509Certificate[] chain = TlsUtil.captureServerChain(endpoint.getHost(), endpoint.getPort());
      logger.info("Pinning server chain of {}: subject={}, SANs={}", endpoint,
        chain[0].getSubjectX500Principal(), chain[0].getSubjectAlternativeNames());
      return TlsUtil.createAndSaveTruststore(chain, TlsUtil.tempTruststorePath("sch-pinned"),
        TRUSTSTORE_PASSWORD);
    } catch (Exception e) {
      throw new IllegalStateException("TLS trust bootstrap failed for " + endpoint, e);
    }
  }

  /**
   * All {@link SslOptions} defaults except the truststore are kept, so the client under test
   * performs full certificate-chain and hostname verification.
   */
  private static SslOptions sslOptionsFor(Path truststore) {
    return SslOptions.builder().truststore(truststore.toFile(), TRUSTSTORE_PASSWORD.toCharArray())
        .build();
  }

  private void deleteServerTruststore() {
    if (serverTruststore == null) {
      return;
    }
    try {
      Files.deleteIfExists(serverTruststore);
    } catch (IOException e) {
      logger.warn("Failed to delete truststore {}", serverTruststore, e);
    }
    serverTruststore = null;
  }

  @AfterEach
  final void tearDownClient() {
    try {
      client = closeIfOpen(client);
    } finally {
      deleteServerTruststore();
    }
  }

  /** Closes the client if one is open; returns null so the field is cleared in one step. */
  static RedisClient closeIfOpen(RedisClient client) {
    if (client != null) {
      client.close();
    }
    return null;
  }
}