Skip to content

Commit

Permalink
Lettuce EntraID integration tests (#3133)
Browse files Browse the repository at this point in the history
* EntraId integration test - integrate with cae infra
 - Read test endpoint's configuration from endpoint's json
 - Invoke only EntraId related test:
  mvn  integration-test -Pentraid-it

* Load EntraIdTest
 from environment variables directly

remove dotenv dependency

* Replace whoami check with get/set to not depend on username

* Remove deprecated dnsResolver

* use mset to test default connection, and ping for individual node connections

* Add EntraId managed identity integration test
  • Loading branch information
ggivo authored Jan 21, 2025
1 parent 5911303 commit ee7c46c
Show file tree
Hide file tree
Showing 7 changed files with 584 additions and 120 deletions.
38 changes: 32 additions & 6 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,6 @@
<version>0.1.1-beta1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.github.cdimascio</groupId>
<artifactId>dotenv-java</artifactId>
<version>2.2.0</version>
<scope>test</scope>
</dependency>
<!-- Start of core dependencies -->

<dependency>
Expand Down Expand Up @@ -1044,6 +1038,38 @@
<profile>
<id>ci</id>
</profile>
<profile>
<id>entraid-it</id>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skipTests>true</skipTests>
</configuration>
</plugin>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<configuration>
<groups>entraid</groups>
<skipITs>false</skipITs>
<includes>
<include>**/*IntegrationTests</include>
</includes>
</configuration>
<executions>
<execution>
<phase>integration-test</phase>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>

<profile>

Expand Down
5 changes: 5 additions & 0 deletions src/test/java/io/lettuce/TestTags.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,9 @@ public class TestTags {
*/
public static final String API_GENERATOR = "api_generator";

/**
* Tag for EntraId integration tests (require a running environment with configured microsoft EntraId authentication)
*/
public static final String ENTRA_ID = "entraid";

}
140 changes: 140 additions & 0 deletions src/test/java/io/lettuce/authx/EntraIdClusterIntegrationTests.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package io.lettuce.authx;

import io.lettuce.core.ClientOptions;
import io.lettuce.core.RedisURI;
import io.lettuce.core.SocketOptions;
import io.lettuce.core.TimeoutOptions;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.cluster.ClusterClientOptions;
import io.lettuce.core.cluster.ClusterTopologyRefreshOptions;
import io.lettuce.core.cluster.RedisClusterClient;
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
import io.lettuce.core.resource.ClientResources;
import io.lettuce.core.resource.DnsResolver;
import io.lettuce.test.env.Endpoints;
import io.lettuce.test.env.Endpoints.Endpoint;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import redis.clients.authentication.core.TokenAuthConfig;
import redis.clients.authentication.entraid.EntraIDTokenAuthConfigBuilder;

import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutionException;

import static io.lettuce.TestTags.ENTRA_ID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

@Tag(ENTRA_ID)
public class EntraIdClusterIntegrationTests {

private static final EntraIdTestContext testCtx = EntraIdTestContext.DEFAULT;

private static TokenBasedRedisCredentialsProvider credentialsProvider;

private static RedisClusterClient clusterClient;

private static ClientResources resources;

private static Endpoint cluster;

@BeforeAll
public static void setup() {
cluster = Endpoints.DEFAULT.getEndpoint("cluster-entraid-acl");
if (cluster != null) {
Assumptions.assumeTrue(testCtx.getClientId() != null && testCtx.getClientSecret() != null,
"Skipping EntraID tests. Azure AD credentials not provided!");

// Configure timeout options to assure fast test failover
ClusterClientOptions clientOptions = ClusterClientOptions.builder()
.socketOptions(SocketOptions.builder().connectTimeout(Duration.ofSeconds(1)).build())
.timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(1)))
// enable re-authentication
.reauthenticateBehavior(ClientOptions.ReauthenticateBehavior.ON_NEW_CREDENTIALS).build();

TokenAuthConfig tokenAuthConfig = EntraIDTokenAuthConfigBuilder.builder().clientId(testCtx.getClientId())
.secret(testCtx.getClientSecret()).authority(testCtx.getAuthority()).scopes(testCtx.getRedisScopes())
.expirationRefreshRatio(0.0000001F).build();

credentialsProvider = TokenBasedRedisCredentialsProvider.create(tokenAuthConfig);

resources = ClientResources.builder()
// .dnsResolver(DnsResolver.jvmDefault())
.build();

List<RedisURI> seedURI = new ArrayList<>();
for (String addr : cluster.getRawEndpoints().get(0).getAddr()) {
seedURI.add(RedisURI.builder().withAuthentication(credentialsProvider).withHost(addr)
.withPort(cluster.getRawEndpoints().get(0).getPort()).build());
}

clusterClient = RedisClusterClient.create(resources, seedURI);
clusterClient.setOptions(clientOptions);
}
}

@AfterAll
public static void cleanup() {
if (credentialsProvider != null) {
credentialsProvider.close();
}
if (resources != null) {
resources.shutdown();
}
}

// T.1.1
// Verify authentication using Azure AD with service principals using Redis Cluster Client
@Test
public void clusterWithSecret_azureServicePrincipalIntegrationTest() throws ExecutionException, InterruptedException {
assumeTrue(cluster != null, "Skipping EntraID tests. Redis host with enabled EntraId not provided!");

try (StatefulRedisClusterConnection<String, String> defaultConnection = clusterClient.connect()) {
RedisAdvancedClusterCommands<String, String> sync = defaultConnection.sync();
String keyPrefix = UUID.randomUUID().toString();
Map<String, String> mset = prepareMset(keyPrefix);

assertThat(sync.mset(mset)).isEqualTo("OK");

for (String mykey : mset.keySet()) {
assertThat(defaultConnection.sync().get(mykey)).isEqualTo("value-" + mykey);
assertThat(defaultConnection.async().get(mykey).get()).isEqualTo("value-" + mykey);
assertThat(defaultConnection.reactive().get(mykey).block()).isEqualTo("value-" + mykey);
}
assertThat(sync.del(mset.keySet().toArray(new String[0]))).isEqualTo(mset.keySet().size());

// Test connections to each node
defaultConnection.getPartitions().forEach((partition) -> {
StatefulRedisConnection<?, ?> nodeConnection = defaultConnection.getConnection(partition.getNodeId());
assertThat(nodeConnection.sync().ping()).isEqualTo("PONG");
});

defaultConnection.getPartitions().forEach((partition) -> {
StatefulRedisConnection<?, ?> nodeConnection = defaultConnection.getConnection(partition.getUri().getHost(),
partition.getUri().getPort());
assertThat(nodeConnection.sync().ping()).isEqualTo("PONG");
});
}
}

Map<String, String> prepareMset(String keyPrefix) {
Map<String, String> mset = new HashMap<>();
for (char c = 'a'; c <= 'z'; c++) {
String keySuffix = new String(new char[] { c, c, c }); // Generates "aaa", "bbb", etc.
String key = String.format("%s-{%s}", keyPrefix, keySuffix);
mset.put(key, "value-" + key);
}
return mset;
}

}
97 changes: 45 additions & 52 deletions src/test/java/io/lettuce/authx/EntraIdIntegrationTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,67 +9,69 @@
import io.lettuce.core.TransactionResult;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.async.RedisAsyncCommands;
import io.lettuce.core.api.sync.RedisCommands;
import io.lettuce.core.cluster.ClusterClientOptions;
import io.lettuce.core.cluster.RedisClusterClient;
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
import io.lettuce.core.pubsub.StatefulRedisPubSubConnection;
import io.lettuce.core.support.PubSubTestListener;
import io.lettuce.test.Wait;
import io.lettuce.test.env.Endpoints;
import io.lettuce.test.env.Endpoints.Endpoint;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import redis.clients.authentication.core.TokenAuthConfig;
import redis.clients.authentication.entraid.EntraIDTokenAuthConfigBuilder;

import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

import static io.lettuce.TestTags.ENTRA_ID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

@Tag(ENTRA_ID)
public class EntraIdIntegrationTests {

private static final EntraIdTestContext testCtx = EntraIdTestContext.DEFAULT;;

private static ClusterClientOptions clientOptions;
private static final EntraIdTestContext testCtx = EntraIdTestContext.DEFAULT;

private static TokenBasedRedisCredentialsProvider credentialsProvider;

private static RedisClient client;

private static RedisClusterClient clusterClient;
private static Endpoint standalone;

@BeforeAll
public static void setup() {
Assumptions.assumeTrue(testCtx.host() != null && !testCtx.host().isEmpty(),
"Skipping EntraID tests. Redis host with enabled EntraId not provided!");

// Configure timeout options to assure fast test failover
clientOptions = ClusterClientOptions.builder()
.socketOptions(SocketOptions.builder().connectTimeout(Duration.ofSeconds(1)).build())
.timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(1)))
.reauthenticateBehavior(ClientOptions.ReauthenticateBehavior.ON_NEW_CREDENTIALS).build();

TokenAuthConfig tokenAuthConfig = EntraIDTokenAuthConfigBuilder.builder().clientId(testCtx.getClientId())
.secret(testCtx.getClientSecret()).authority(testCtx.getAuthority()).scopes(testCtx.getRedisScopes())
.expirationRefreshRatio(0.0000001F).build();

credentialsProvider = TokenBasedRedisCredentialsProvider.create(tokenAuthConfig);

RedisURI uri = RedisURI.builder().withHost(testCtx.host()).withPort(testCtx.port())
.withAuthentication(credentialsProvider).build();
standalone = Endpoints.DEFAULT.getEndpoint("standalone-entraid-acl");
if (standalone != null) {
Assumptions.assumeTrue(testCtx.getClientId() != null && testCtx.getClientSecret() != null,
"Skipping EntraID tests. Azure AD credentials not provided!");
// Configure timeout options to assure fast test failover
ClusterClientOptions clientOptions = ClusterClientOptions.builder()
.socketOptions(SocketOptions.builder().connectTimeout(Duration.ofSeconds(1)).build())
.timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(1)))
// enable re-authentication
.reauthenticateBehavior(ClientOptions.ReauthenticateBehavior.ON_NEW_CREDENTIALS).build();

TokenAuthConfig tokenAuthConfig = EntraIDTokenAuthConfigBuilder.builder().clientId(testCtx.getClientId())
.secret(testCtx.getClientSecret()).authority(testCtx.getAuthority()).scopes(testCtx.getRedisScopes())
.expirationRefreshRatio(0.0000001F).build();

credentialsProvider = TokenBasedRedisCredentialsProvider.create(tokenAuthConfig);

RedisURI uri = RedisURI.create((standalone.getEndpoints().get(0)));
uri.setCredentialsProvider(credentialsProvider);
client = RedisClient.create(uri);
client.setOptions(clientOptions);

client = RedisClient.create(uri);
client.setOptions(clientOptions);

RedisURI clusterUri = RedisURI.builder().withHost(testCtx.clusterHost().get(0)).withPort(testCtx.clusterPort())
.withAuthentication(credentialsProvider).build();
clusterClient = RedisClusterClient.create(clusterUri);
clusterClient.setOptions(clientOptions);
}
}

@AfterAll
Expand All @@ -83,35 +85,24 @@ public static void cleanup() {
// Verify authentication using Azure AD with service principals using Redis Standalone client
@Test
public void standaloneWithSecret_azureServicePrincipalIntegrationTest() throws ExecutionException, InterruptedException {
try (StatefulRedisConnection<String, String> connection = client.connect()) {
assertThat(connection.sync().aclWhoami()).isEqualTo(testCtx.getSpOID());
assertThat(connection.async().aclWhoami().get()).isEqualTo(testCtx.getSpOID());
assertThat(connection.reactive().aclWhoami().block()).isEqualTo(testCtx.getSpOID());
}
}

// T.1.1
// Verify authentication using Azure AD with service principals using Redis Cluster Client
@Test
public void clusterWithSecret_azureServicePrincipalIntegrationTest() throws ExecutionException, InterruptedException {

try (StatefulRedisClusterConnection<String, String> connection = clusterClient.connect()) {
assertThat(connection.sync().aclWhoami()).isEqualTo(testCtx.getSpOID());
assertThat(connection.async().aclWhoami().get()).isEqualTo(testCtx.getSpOID());
assertThat(connection.reactive().aclWhoami().block()).isEqualTo(testCtx.getSpOID());
assumeTrue(standalone != null, "Skipping EntraID tests. Redis host with enabled EntraId not provided!");

connection.getPartitions().forEach((partition) -> {
try (StatefulRedisConnection<?, ?> nodeConnection = connection.getConnection(partition.getNodeId())) {
assertThat(nodeConnection.sync().aclWhoami()).isEqualTo(testCtx.getSpOID());
}
});
try (StatefulRedisConnection<String, String> connection = client.connect()) {
RedisCommands<String, String> sync = connection.sync();
String key = UUID.randomUUID().toString();
sync.set(key, "value");
assertThat(connection.sync().get(key)).isEqualTo("value");
assertThat(connection.async().get(key).get()).isEqualTo("value");
assertThat(connection.reactive().get(key).block()).isEqualTo("value");
sync.del(key);
}
}

// T.2.2
// Test that the Redis client is not blocked/interrupted during token renewal.
@Test
public void renewalDuringOperationsTest() throws InterruptedException {
assumeTrue(standalone != null, "Skipping EntraID tests. Redis host with enabled EntraId not provided!");

// Counter to track the number of command cycles
AtomicInteger commandCycleCount = new AtomicInteger(0);
Expand Down Expand Up @@ -162,6 +153,8 @@ public void renewalDuringOperationsTest() throws InterruptedException {
// Test basic Pub/Sub functionality is not blocked/interrupted during token renewal.
@Test
public void renewalDuringPubSubOperationsTest() throws InterruptedException {
assumeTrue(standalone != null, "Skipping EntraID tests. Redis host with enabled EntraId not provided!");

try (StatefulRedisPubSubConnection<String, String> connectionPubSub = client.connectPubSub();
StatefulRedisPubSubConnection<String, String> connectionPubSub1 = client.connectPubSub()) {

Expand All @@ -183,7 +176,7 @@ public void renewalDuringPubSubOperationsTest() throws InterruptedException {
latch.countDown();
});

assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); // Wait for at least 10 token renewals
assertThat(latch.await(2, TimeUnit.SECONDS)).isTrue(); // Wait for at least 10 token renewals
pubsubThread.join(); // Wait for the pub/sub thread to finish

// Verify that all messages were received
Expand Down
Loading

0 comments on commit ee7c46c

Please sign in to comment.