diff --git a/test-framework/clustering/pom.xml b/test-framework/clustering/pom.xml index 5b1791d6fd4..c6e9088957f 100644 --- a/test-framework/clustering/pom.xml +++ b/test-framework/clustering/pom.xml @@ -18,5 +18,9 @@ keycloak-test-framework-core ${project.version} + + io.vertx + vertx-http-proxy + \ No newline at end of file diff --git a/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancer.java b/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancer.java index 1b56bcdec13..232e6845a48 100644 --- a/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancer.java +++ b/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancer.java @@ -1,26 +1,72 @@ package org.keycloak.testframework.clustering; -import org.keycloak.testframework.server.ClusteredKeycloakServer; -import org.keycloak.testframework.server.KeycloakUrls; - import java.util.HashMap; +import io.vertx.httpproxy.ProxyContext; +import io.vertx.httpproxy.ProxyInterceptor; +import io.vertx.httpproxy.ProxyResponse; +import org.jboss.logging.Logger; +import org.keycloak.testframework.server.ClusteredKeycloakServer; +import org.keycloak.testframework.server.KeycloakUrls; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClient; +import io.vertx.core.http.HttpServer; +import io.vertx.httpproxy.HttpProxy; + public class LoadBalancer { + + private static final Logger LOGGER = Logger.getLogger(LoadBalancer.class); + public static final String HOSTNAME = "http://localhost:9999"; + private final ClusteredKeycloakServer server; - private final HashMap urls = new HashMap<>(); + private final HashMap urls = new HashMap<>(); + private final Vertx vertx; + private final HttpProxy proxy; public LoadBalancer(ClusteredKeycloakServer server) { this.server = server; + + this.vertx = Vertx.vertx(); + HttpClient proxyClient = vertx.createHttpClient(); + proxy = HttpProxy.reverseProxy(proxyClient); + proxy.addInterceptor(new ProxyInterceptor() { + @Override + public Future handleProxyRequest(ProxyContext context) { + LOGGER.debugf("Proxy request intercepted: %s", context.request().getURI()); + return ProxyInterceptor.super.handleProxyRequest(context); + } + }); + node(0); + + HttpServer proxyServer = vertx.createHttpServer(); + proxyServer.requestHandler(proxy).listen(9999, "localhost"); } - public KeycloakUrls node(int nodeIndex) { - if (nodeIndex >= server.clusterSize()) { - throw new IllegalArgumentException("Node index out of bounds. Requested nodeIndex: %d, cluster size: %d".formatted(server.clusterSize(), nodeIndex)); + public void node(int index) { + Origin origin = origin(index); + LOGGER.debugf("Setting proxy origin to: %s:%d", origin.host, origin.port); + proxy.origin(origin.port, origin.host); + } + + public KeycloakUrls nodeUrls(int index) { + return origin(index).urls; + } + + private Origin origin(int index) { + if (index >= server.clusterSize()) { + throw new IllegalArgumentException("Node index out of bounds. Requested nodeIndex: %d, cluster size: %d".formatted(server.clusterSize(), index)); } - return urls.computeIfAbsent(nodeIndex, i -> new KeycloakUrls(server.getBaseUrl(i), server.getManagementBaseUrl(i))); + return urls.computeIfAbsent(index, i -> + new Origin("localhost", server.getBasePort(i), new KeycloakUrls(server.getBaseUrl(i), server.getManagementBaseUrl(i))) + ); } - public int clusterSize() { - return server.clusterSize(); + public void close() { + Future.await(vertx.close()); + } + + record Origin(String host, int port, KeycloakUrls urls) { } } diff --git a/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancerSupplier.java b/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancerSupplier.java index de6344c4b0d..2010403be91 100644 --- a/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancerSupplier.java +++ b/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancerSupplier.java @@ -4,10 +4,13 @@ import org.keycloak.testframework.annotations.InjectLoadBalancer; import org.keycloak.testframework.injection.InstanceContext; import org.keycloak.testframework.injection.RequestedInstance; import org.keycloak.testframework.injection.Supplier; +import org.keycloak.testframework.injection.SupplierOrder; import org.keycloak.testframework.server.ClusteredKeycloakServer; import org.keycloak.testframework.server.KeycloakServer; +import org.keycloak.testframework.server.KeycloakServerConfigBuilder; +import org.keycloak.testframework.server.KeycloakServerConfigInterceptor; -public class LoadBalancerSupplier implements Supplier { +public class LoadBalancerSupplier implements Supplier, KeycloakServerConfigInterceptor { @Override public LoadBalancer getValue(InstanceContext instanceContext) { @@ -20,8 +23,23 @@ public class LoadBalancerSupplier implements Supplier instanceContext) { + instanceContext.getValue().close(); + } + @Override public boolean compatible(InstanceContext a, RequestedInstance b) { return true; } + + @Override + public int order() { + return SupplierOrder.BEFORE_REALM; + } + + @Override + public KeycloakServerConfigBuilder intercept(KeycloakServerConfigBuilder serverConfig, InstanceContext instanceContext) { + return serverConfig.option("hostname", LoadBalancer.HOSTNAME); + } } diff --git a/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServer.java b/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServer.java index c3a7f136cc6..47c8cffc960 100644 --- a/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServer.java +++ b/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServer.java @@ -17,15 +17,12 @@ package org.keycloak.testframework.server; -import java.net.URISyntaxException; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.Arrays; import java.util.Objects; -import io.quarkus.bootstrap.utils.BuildToolHelper; import org.jboss.logging.Logger; import org.keycloak.it.utils.DockerKeycloakDistribution; +import org.keycloak.testframework.clustering.LoadBalancer; import org.keycloak.testframework.database.JBossLogConsumer; import org.testcontainers.images.RemoteDockerImage; import org.testcontainers.utility.DockerImageName; @@ -122,7 +119,7 @@ public class ClusteredKeycloakServer implements KeycloakServer { @Override public String getBaseUrl() { - return getBaseUrl(0); + return LoadBalancer.HOSTNAME; } @Override @@ -130,8 +127,12 @@ public class ClusteredKeycloakServer implements KeycloakServer { return getManagementBaseUrl(0); } + public int getBasePort(int index) { + return containers[index].getMappedPort(REQUEST_PORT); + } + public String getBaseUrl(int index) { - return "http://localhost:%d".formatted(containers[index].getMappedPort(REQUEST_PORT)); + return "http://localhost:%d".formatted(getBasePort(index)); } public String getManagementBaseUrl(int index) { diff --git a/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServerSupplier.java b/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServerSupplier.java index b7fbc148237..34f558ef7ee 100644 --- a/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServerSupplier.java +++ b/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServerSupplier.java @@ -37,6 +37,4 @@ public class ClusteredKeycloakServerSupplier extends AbstractKeycloakServerSuppl protected String cache() { return "ispn"; } - - } diff --git a/tests/clustering/pom.xml b/tests/clustering/pom.xml index 70e97805ee2..1abcc44a49c 100644 --- a/tests/clustering/pom.xml +++ b/tests/clustering/pom.xml @@ -41,5 +41,27 @@ org.keycloak.testframework keycloak-test-framework-db-postgres + + org.keycloak.testframework + keycloak-test-framework-oauth + + + org.keycloak.testframework + keycloak-test-framework-ui + + + + + + maven-surefire-plugin + + + org.jboss.logmanager.LogManager + io.quarkus.bootstrap.forkjoin.QuarkusForkJoinWorkerThreadFactory + + + + + \ No newline at end of file diff --git a/tests/clustering/src/test/java/org/keycloak/tests/compatibility/ClusteredOAuthClientTest.java b/tests/clustering/src/test/java/org/keycloak/tests/compatibility/ClusteredOAuthClientTest.java new file mode 100644 index 00000000000..b2515ea2b6f --- /dev/null +++ b/tests/clustering/src/test/java/org/keycloak/tests/compatibility/ClusteredOAuthClientTest.java @@ -0,0 +1,92 @@ +package org.keycloak.tests.compatibility; + +import org.htmlunit.WebClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.keycloak.testframework.annotations.InjectLoadBalancer; +import org.keycloak.testframework.annotations.InjectUser; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.clustering.LoadBalancer; +import org.keycloak.testframework.oauth.OAuthClient; +import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; +import org.keycloak.testframework.realm.ManagedUser; +import org.keycloak.testframework.realm.UserConfig; +import org.keycloak.testframework.realm.UserConfigBuilder; +import org.keycloak.testframework.ui.annotations.InjectWebDriver; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.htmlunit.HtmlUnitDriver; + +@KeycloakIntegrationTest +public class ClusteredOAuthClientTest { + + @InjectUser(config = OAuthUserConfig.class) + ManagedUser user; + + @InjectLoadBalancer + LoadBalancer loadBalancer; + + @InjectOAuthClient + OAuthClient oauth; + + @InjectWebDriver + WebDriver driver; + + @AfterEach + public void cleanup() { + loadBalancer.node(0); + driver.navigate().to("about:blank"); + if (driver instanceof HtmlUnitDriver htmlUnitDriver) { + WebClient webClient = htmlUnitDriver.getWebClient(); + webClient.getCache().clear(); + webClient.getCookieManager().clearCookies(); + webClient.reset(); + } + } + + @ParameterizedTest + @CsvSource({"0, 1", "1, 0"}) + public void testAccessTokenRefresh(int grantNode, int refreshNode) { + loadBalancer.node(grantNode); + AccessTokenResponse accessTokenResponse = oauth.doPasswordGrantRequest(user.getUsername(), user.getPassword()); + + loadBalancer.node(refreshNode); + AccessTokenResponse refreshResponse = oauth.doRefreshTokenRequest(accessTokenResponse.getRefreshToken()); + Assertions.assertTrue(refreshResponse.isSuccess()); + Assertions.assertNotEquals(accessTokenResponse.getAccessToken(), refreshResponse.getAccessToken()); + } + + @ParameterizedTest + @CsvSource({ + "0, 0, 1", + "0, 1, 0", + "0, 1, 1", + "1, 0, 0", + "1, 1, 0", + "1, 0, 1", + }) + public void testLoginLogout(int loginNode, int tokenNode, int logoutNode) { + loadBalancer.node(loginNode); + AuthorizationEndpointResponse authResponse = oauth.doLogin(user.getUsername(), user.getPassword()); + Assertions.assertTrue(authResponse.isRedirected()); + + loadBalancer.node(tokenNode); + AccessTokenResponse accessTokenResponse = oauth.doPasswordGrantRequest(user.getUsername(), user.getPassword()); + Assertions.assertTrue(accessTokenResponse.isSuccess()); + + loadBalancer.node(logoutNode); + oauth.logoutForm().idTokenHint(accessTokenResponse.getIdToken()).open(); + } + + public static class OAuthUserConfig implements UserConfig { + @Override + public UserConfigBuilder configure(UserConfigBuilder user) { + return user.username("myuser").name("First", "Last") + .email("test@local") + .password("password"); + } + } +} diff --git a/tests/clustering/src/test/java/org/keycloak/tests/compatibility/MixedVersionClusterTest.java b/tests/clustering/src/test/java/org/keycloak/tests/compatibility/MixedVersionClusterTest.java deleted file mode 100644 index 1b18cbd8357..00000000000 --- a/tests/clustering/src/test/java/org/keycloak/tests/compatibility/MixedVersionClusterTest.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2025 Red Hat, Inc. and/or its affiliates - * and other contributors as indicated by the @author tags. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.keycloak.tests.compatibility; - -import java.util.concurrent.TimeUnit; - -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.Test; -import org.keycloak.testframework.annotations.InjectLoadBalancer; -import org.keycloak.testframework.annotations.KeycloakIntegrationTest; -import org.keycloak.testframework.clustering.LoadBalancer; - -@KeycloakIntegrationTest -public class MixedVersionClusterTest { - - @InjectLoadBalancer - LoadBalancer loadBalancer; - - @Test - public void testUrls() throws InterruptedException { - // TODO annotation based to skip if running in non-clustered mode. - Assumptions.assumeTrue(loadBalancer.clusterSize() == 2); - System.out.println("url0->" + loadBalancer.node(0).getBaseUrl()); - System.out.println("url1->" + loadBalancer.node(1).getBaseUrl()); - Thread.sleep(TimeUnit.MINUTES.toMillis(1)); - } -} diff --git a/tests/clustering/src/test/resources/keycloak-test.properties b/tests/clustering/src/test/resources/keycloak-test.properties index f7b5160aaf8..0b26865a61e 100644 --- a/tests/clustering/src/test/resources/keycloak-test.properties +++ b/tests/clustering/src/test/resources/keycloak-test.properties @@ -1,4 +1,6 @@ kc.test.server=cluster +# As clustering is dependent on JDBC_PING, we must use a real database for cluster tests +kc.test.database=postgres kc.test.log.level=WARN @@ -9,4 +11,5 @@ kc.test.log.category."org.keycloak.tests".level=INFO kc.test.log.category."testinfo".level=INFO kc.test.log.category."org.keycloak".level=WARN kc.test.log.category."managed.keycloak".level=WARN -kc.test.log.category."managed.db".level=WARN \ No newline at end of file +kc.test.log.category."managed.db".level=WARN +kc.test.log.category."org.keycloak.testframework.clustering".level=WARN \ No newline at end of file