Add JavaDoc for most important parts of the new test framework

Closes #46170

Signed-off-by: stianst <stianst@gmail.com>
Signed-off-by: Stian Thorgersen <stianst@gmail.com>
Co-authored-by: Šimon Vacek <86605314+vaceksimon@users.noreply.github.com>
This commit is contained in:
Stian Thorgersen 2026-02-20 11:17:09 +01:00 committed by GitHub
parent 2d3258a209
commit 337e94d5a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 762 additions and 5 deletions

View file

@ -1,5 +1,10 @@
package org.keycloak.testframework;
/**
* FatalTestClassException is thrown when a test class contains invalid configuration, or there is a non-recoverable
* problem when setting up managed resources for a test, for example the server can not be started. When a
* FatalTestClassException is thrown subsequent test methods in a test class will be skipped.
*/
public class FatalTestClassException extends RuntimeException {
public FatalTestClassException(String message) {

View file

@ -6,14 +6,32 @@ import java.util.Map;
import org.keycloak.testframework.injection.Supplier;
/**
* Test framework extensions allows adding additional suppliers to the test framework
*/
public interface TestFrameworkExtension {
/**
* List of suppliers provided by the extension
* @return supplier list
*/
List<Supplier<?, ?>> suppliers();
/**
* List of value types that are always created when running tests. Extensions usually does not need to implement
* this method
* @return the list of value types that are always requested for tests
*/
default List<Class<?>> alwaysEnabledValueTypes() {
return Collections.emptyList();
}
/**
* List of aliases for value types. By default, {@code getSimpleName} is used as the name for a value type, implementing
* this method allows setting custom aliases for the value type. For example the core extension has the alias
* {@code server} for the value type {@code KeycloakServer}
* @return map where key is the value type and value is the alias
*/
default Map<Class<?>, String> valueTypeAliases() {
return Collections.emptyMap();
}

View file

@ -6,18 +6,38 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Injects a {@link org.keycloak.admin.client.Keycloak} instance to access Keycloak Admin APIs
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectAdminClient {
/**
* A ref must be set if a test requires multiple instances
*/
String ref() default "";
/**
* Set to attach to the non-default realm
*/
String realmRef() default "";
/**
* <code>BOOTSTRAP</code> attaches to the master realm and global test client, while <code>MANAGED_REALM</code>
* attaches to a managed realm using the specified client or user. When using <code>MANAGED_REALM</code> either
* client or user has to be set
*/
Mode mode() default Mode.BOOTSTRAP;
/**
* The client to authenticate as
*/
String client() default "";
/**
* The user to authenticate as
*/
String user() default "";
enum Mode {

View file

@ -8,11 +8,21 @@ import java.lang.annotation.Target;
import org.keycloak.testframework.injection.LifeCycle;
/**
* Injects a {@link org.keycloak.testframework.admin.AdminClientFactory} instance that can be used to create
* {@link org.keycloak.admin.client.Keycloak} instances.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectAdminClientFactory {
/**
* A ref must be set if a test requires multiple instances
*/
String ref() default "";
/**
* Controls the lifecycle of the resource
*/
LifeCycle lifecycle() default LifeCycle.CLASS;
}

View file

@ -5,12 +5,21 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Injects a {@link org.keycloak.testframework.events.AdminEvents} instance that can be used to poll admin events from Keycloak
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectAdminEvents {
/**
* A ref must be set if a test requires multiple instances
*/
String ref() default "";
/**
* Set to attach to the non-default realm
*/
String realmRef() default "";
}

View file

@ -9,16 +9,28 @@ import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.realm.ClientConfig;
import org.keycloak.testframework.realm.DefaultClientConfig;
/**
* Injects a {@link org.keycloak.testframework.realm.ManagedClient} used to create a client within the realm
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectClient {
/**
* Used to define a custom configuration for the client
*/
Class<? extends ClientConfig> config() default DefaultClientConfig.class;
/**
* Controls the lifecycle of the resource
*/
LifeCycle lifecycle() default LifeCycle.CLASS;
String ref() default "";
/**
* Set to attach to the non-default realm
*/
String realmRef() default "";
/**

View file

@ -5,6 +5,9 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Injects a {@link org.keycloak.testframework.crypto.CryptoHelper} with various crypto utilities
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectCryptoHelper {

View file

@ -5,6 +5,10 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Injects dependencies into configuration classes; for example if a {@link org.keycloak.testframework.realm.ClientConfig}
* needs to access the {@link org.keycloak.testframework.realm.ManagedRealm} to set the correct configuration.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectDependency {

View file

@ -5,12 +5,21 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Injects a {@link org.keycloak.testframework.events.Events} instance that can be used to poll login events from Keycloak
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectEvents {
/**
* A ref must be set if a test requires multiple instances
*/
String ref() default "";
/**
* Set to attach to the non-default realm
*/
String realmRef() default "";
}

View file

@ -5,8 +5,14 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Injects a {@link org.apache.http.client.HttpClient} that can be used to do HTTP requests within tests. See
* {@link InjectSimpleHttp} as an alternative that provides a simpler API.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectHttpClient {
boolean followRedirects() default true;
}

View file

@ -5,6 +5,11 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Injects a {@link com.sun.net.httpserver.HttpServer} instance that can be used to register or unregister additional
* contexts to the Mock HTTP server used for tests. This should usually only be used by suppliers and not directly
* by test.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectHttpServer {

View file

@ -7,9 +7,15 @@ import java.lang.annotation.Target;
import org.keycloak.testframework.injection.LifeCycle;
/**
* Injects a {@link org.keycloak.testframework.infinispan.InfinispanServer} that starts an external Infinispan server
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface InjectInfinispanServer {
/**
* Controls the lifecycle of the resource
*/
LifeCycle lifecycle() default LifeCycle.GLOBAL;
}

View file

@ -5,6 +5,10 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Injects a {@link org.keycloak.testframework.server.KeycloakUrls} instance that can be used to discover various
* endpoints offered by the Keycloak server
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectKeycloakUrls {

View file

@ -13,12 +13,24 @@ import org.keycloak.testframework.realm.RealmConfig;
@Target(ElementType.FIELD)
public @interface InjectRealm {
/**
* Used to define a custom configuration for the realm
*/
Class<? extends RealmConfig> config() default DefaultRealmConfig.class;
/**
* Loads custom configuration from a json file on the classpath
*/
String fromJson() default "";
/**
* Controls the lifecycle of the resource
*/
LifeCycle lifecycle() default LifeCycle.CLASS;
/**
* A ref must be set if a test requires multiple instances
*/
String ref() default "";
/**

View file

@ -6,6 +6,9 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Injects a {@link org.keycloak.http.simple.SimpleHttp} that can be used to do HTTP requests within tests.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectSimpleHttp {

View file

@ -5,6 +5,10 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Injects a {@link org.keycloak.testframework.events.SysLogServer} that can be used to register listeners to obtain
* logging events from Keycloak over syslog.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectSysLogServer {

View file

@ -13,6 +13,9 @@ import org.keycloak.testframework.injection.LifeCycle;
@Target(ElementType.FIELD)
public @interface InjectTestDatabase {
/**
* Controls the lifecycle of the resource
*/
LifeCycle lifecycle() default LifeCycle.GLOBAL;
Class<? extends DatabaseConfig> config() default DefaultDatabaseConfig.class;

View file

@ -15,8 +15,14 @@ public @interface InjectUser {
Class<? extends UserConfig> config() default DefaultUserConfig.class;
/**
* Controls the lifecycle of the resource
*/
LifeCycle lifecycle() default LifeCycle.CLASS;
/**
* A ref must be set if a test requires multiple instances
*/
String ref() default "";
String realmRef() default "";

View file

@ -11,11 +11,17 @@ import org.keycloak.testframework.server.KeycloakServerConfig;
import org.junit.jupiter.api.extension.ExtendWith;
/**
* Enables the test framework for tests
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@ExtendWith({KeycloakIntegrationTestExtension.class})
public @interface KeycloakIntegrationTest {
/**
* Used to define custom configuration for the Keycloak server
*/
Class<? extends KeycloakServerConfig> config() default DefaultKeycloakServerConfig.class;
}

View file

@ -5,6 +5,10 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Methods annotated with <code>@TestCleanup</code> are invoked by the test framework after all tests methods are
* completed
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface TestCleanup {

View file

@ -5,6 +5,10 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Methods annotated with <code>@TestSetup</code> are invoked by the test framework after all managed resources are
* injected into the test and before any test methods are executed
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface TestSetup {

View file

@ -8,6 +8,9 @@ import java.lang.annotation.Target;
import org.junit.jupiter.api.extension.ExtendWith;
/**
* Tests annotated with <code>@DisabledForDatabases</code> will be skipped for the specified databases
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented

View file

@ -8,6 +8,9 @@ import java.lang.annotation.Target;
import org.junit.jupiter.api.extension.ExtendWith;
/**
* Tests annotated with <code>@DisabledForServers</code> will be skipped for the specified server modes
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented

View file

@ -1,5 +1,10 @@
package org.keycloak.testframework.database;
/**
* Declarative configuration for the managed database
*/
public interface DatabaseConfig {
DatabaseConfigBuilder configure(DatabaseConfigBuilder database);
}

View file

@ -13,16 +13,34 @@ public class DatabaseConfigBuilder {
return new DatabaseConfigBuilder(rep);
}
/**
* Configure a script to initialise the database on startup
*
* @param initScript path to init script on the classpath
* @return
*/
public DatabaseConfigBuilder initScript(String initScript) {
rep.setInitScript(initScript);
return this;
}
/**
* Set the database name to use, defaults to <code>keycloak</code>
*
* @param database name of the database to use
* @return
*/
public DatabaseConfigBuilder database(String database) {
rep.setDatabase(database);
return this;
}
/**
* Prevent re-use of the database
*
* @param preventReuse set to <code>true</code> to prevent re-use of the database
* @return
*/
public DatabaseConfigBuilder preventReuse(boolean preventReuse) {
rep.setPreventReuse(preventReuse);
return this;

View file

@ -29,6 +29,12 @@ public abstract class AbstractEvents<R> {
this.realm = realm;
}
/**
* Returns the oldest event within the current window. The window is reset for each started test, which means
* any events triggered by previous tests are ignored
*
* @return the oldest event with the current window
*/
public R poll() {
long currentTimeOffset = getCurrentTimeOffset();
if (timeOffset != currentTimeOffset) {
@ -69,14 +75,25 @@ public abstract class AbstractEvents<R> {
return events.poll();
}
/**
* Skip the next event
*/
public void skip() {
skip(1);
}
/**
* Skip the specified number of events
*
* @param events number of events to skip
*/
public void skip(int events) {
skip += events;
}
/**
* Skip all current events
*/
public void skipAll() {
try {
Thread.sleep(1); // Wait 1 ms to make sure time passes
@ -88,6 +105,9 @@ public abstract class AbstractEvents<R> {
events.clear();
}
/**
* Clear all events locally and remotely
*/
public void clear() {
events.clear();
clearServerEvents();
@ -109,7 +129,7 @@ public abstract class AbstractEvents<R> {
protected abstract Logger getLogger();
public long getCurrentTime() {
protected long getCurrentTime() {
return System.currentTimeMillis();
}

View file

@ -31,6 +31,12 @@ public class AdminEventAssertion {
this.expectSuccess = expectSuccess;
}
/**
* Assert an expected successfull event
*
* @param event the event to assert
* @return
*/
public static AdminEventAssertion assertSuccess(AdminEventRepresentation event) {
Assertions.assertFalse(event.getOperationType().endsWith("_ERROR"), "Expected successful event");
return new AdminEventAssertion(event, true)
@ -38,6 +44,12 @@ public class AdminEventAssertion {
.assertValidOperationType();
}
/**
* Assert an expected error event
*
* @param event the event to assert
* @return
*/
public static AdminEventAssertion assertError(AdminEventRepresentation event) {
Assertions.assertTrue(event.getOperationType().endsWith("_ERROR"), "Expected error event");
return new AdminEventAssertion(event, false)
@ -45,6 +57,17 @@ public class AdminEventAssertion {
.assertValidOperationType();
}
/**
* Assert an expected successfull event, with the additional expected parameters. This method should be avoided,
* use method chaining instead.
*
* @param event
* @param operationType
* @param resourcePath
* @param representation
* @param resourceType
* @return
*/
public static AdminEventAssertion assertEvent(AdminEventRepresentation event, OperationType operationType, String resourcePath, Object representation, ResourceType resourceType) {
return assertSuccess(event)
.operationType(operationType)
@ -53,6 +76,16 @@ public class AdminEventAssertion {
.resourceType(resourceType);
}
/**
* Assert an expected successfull event, with the additional expected parameters. This method should be avoided,
* use method chaining instead.
*
* @param event
* @param operationType
* @param resourcePath
* @param resourceType
* @return
*/
public static AdminEventAssertion assertEvent(AdminEventRepresentation event, OperationType operationType, String resourcePath, ResourceType resourceType) {
return assertSuccess(event)
.operationType(operationType)
@ -60,11 +93,24 @@ public class AdminEventAssertion {
.resourceType(resourceType);
}
/**
* Assert the operation type of the event
* @param operationType the expected operation type
* @return
*/
public AdminEventAssertion operationType(OperationType operationType) {
Assertions.assertEquals(operationType.name(), getOperationType());
return this;
}
/**
* Assert the authentication details for the event
*
* @param expectedRealmId the expected authentication realmId
* @param expectedClientId the expected authentication clientId
* @param expectedUserId the expected authentication userId
* @return
*/
public AdminEventAssertion auth(String expectedRealmId, String expectedClientId, String expectedUserId) {
AuthDetailsRepresentation authDetails = event.getAuthDetails();
Assertions.assertEquals(expectedRealmId, authDetails.getRealmId());
@ -73,16 +119,34 @@ public class AdminEventAssertion {
return this;
}
/**
* Assert the type of resource for the event
*
* @param expectedResourceType the expected resource type
* @return
*/
public AdminEventAssertion resourceType(ResourceType expectedResourceType) {
Assertions.assertEquals(expectedResourceType.name(), event.getResourceType());
return this;
}
/**
* Assert the resource path for the event
*
* @param expectedResourcePath the expected resource path
* @return
*/
public AdminEventAssertion resourcePath(String... expectedResourcePath) {
Assertions.assertEquals(String.join("/", expectedResourcePath), event.getResourcePath());
return this;
}
/**
* Assert the representation attached to the event
*
* @param expectedRep the expected representation
* @return
*/
public AdminEventAssertion representation(Object expectedRep) {
String actualRepresentation = event.getRepresentation();
if (expectedRep == null) {

View file

@ -7,6 +7,9 @@ import org.keycloak.testframework.realm.ManagedRealm;
import org.jboss.logging.Logger;
/**
* Poll admin events from the Keycloak server
*/
public class AdminEvents extends AbstractEvents<AdminEventRepresentation> {
private static final Logger LOGGER = Logger.getLogger(AdminEvents.class);

View file

@ -8,6 +8,9 @@ import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Assertions;
/**
* Helper to assert login events
*/
public class EventAssertion {
private final EventRepresentation event;
@ -18,51 +21,109 @@ public class EventAssertion {
this.event = event;
}
/**
* Assert an expected successfull event
*
* @param event the event to assert
* @return
*/
public static EventAssertion assertSuccess(EventRepresentation event) {
Assertions.assertFalse(event.getType().endsWith("_ERROR"), "Expected successful event");
return new EventAssertion(event);
}
/**
* Assert an expected error event
*
* @param event the event to assert
* @return
*/
public static EventAssertion assertError(EventRepresentation event) {
Assertions.assertTrue(event.getType().endsWith("_ERROR"), "Expected error event");
return new EventAssertion(event);
}
/**
* Assert the error message
*
* @param error the expected error message
* @return
*/
public EventAssertion error(String error) {
Assertions.assertEquals(error, event.getError());
return this;
}
/**
* Assert the type of the event
*
* @param type the expected type of the event
* @return
*/
public EventAssertion type(EventType type) {
Assertions.assertEquals(type, EventType.valueOf(event.getType()));
return this;
}
/**
* Assert the event has a sessionId set
*
* @return
*/
public EventAssertion hasSessionId() {
MatcherAssert.assertThat(event.getSessionId(), EventMatchers.isSessionId());
return this;
}
/**
* Assert the event has the <code>code_id</code> details set
* @return
*/
public EventAssertion isCodeId() {
MatcherAssert.assertThat(event.getDetails().get(Details.CODE_ID), EventMatchers.isCodeId());
return this;
}
/**
* Assert the clientId for the event
*
* @param clientId the expected clientId
* @return
*/
public EventAssertion clientId(String clientId) {
Assertions.assertEquals(clientId, event.getClientId());
return this;
}
/**
* Assert the sessionId for the event
*
* @param sessionId the expected sessionId
* @return
*/
public EventAssertion sessionId(String sessionId) {
Assertions.assertEquals(sessionId, event.getSessionId());
return this;
}
/**
* Assert the userId (sub) of the event
*
* @param userId the expected userId
* @return
*/
public EventAssertion userId(String userId) {
Assertions.assertEquals(userId, event.getUserId());
return this;
}
/**
* Assert the event has an entry in the details map with the specified key and value
*
* @param key the expected details key
* @param value the expected details value
* @return
*/
public EventAssertion details(String key, String value) {
if (value != null) {
MatcherAssert.assertThat(event.getDetails(), Matchers.hasEntry(key, value));
@ -72,6 +133,12 @@ public class EventAssertion {
return this;
}
/**
* Assert the event details map does not contain the specified keys
*
* @param keys the list of keys that are not expected in the details map
* @return
*/
public EventAssertion withoutDetails(String... keys) {
for (String key : keys) {
MatcherAssert.assertThat(event.getDetails(), Matchers.not(Matchers.hasKey(key)));

View file

@ -8,23 +8,40 @@ import org.hamcrest.Matcher;
import org.hamcrest.Matchers;
import org.hamcrest.TypeSafeMatcher;
/**
* Matchers to assert event properties
*/
public class EventMatchers {
/**
* Check if value is a UUID
* @return
*/
public static Matcher<String> isUUID() {
return new UUIDMatcher();
}
/**
* Check if value is a code_id
*
* @return
*/
public static Matcher<String> isCodeId() {
// Make the tests pass with the old and the new encoding of code IDs
return Matchers.anyOf(isBase64WithAtLeast128Bits(), isUUID());
}
/**
* Check if value is a session_id
*
* @return
*/
public static Matcher<String> isSessionId() {
// Make the tests pass with the old and the new encoding of sessions
return Matchers.anyOf(isBase64WithAtLeast128Bits(), isUUID());
}
public static Matcher<String> isBase64WithAtLeast128Bits() {
private static Matcher<String> isBase64WithAtLeast128Bits() {
return new TypeSafeMatcher<>() {
private static final Pattern BASE64 = Pattern.compile("[-A-Za-z0-9+/_]*");
@ -43,7 +60,7 @@ public class EventMatchers {
private EventMatchers() {
}
public static class UUIDMatcher extends TypeSafeMatcher<String> {
private static class UUIDMatcher extends TypeSafeMatcher<String> {
@Override
protected boolean matchesSafely(String item) {

View file

@ -1,5 +1,8 @@
package org.keycloak.testframework.https;
/**
* Declarative configuration for managed certificates
*/
public interface CertificatesConfig {
CertificatesConfigBuilder configure(CertificatesConfigBuilder config);

View file

@ -11,6 +11,12 @@ public class CertificatesConfigBuilder {
public CertificatesConfigBuilder() {
}
/**
* Use the specified keystore format
*
* @param keystoreFormat the keystore format to use
* @return
*/
public CertificatesConfigBuilder keystoreFormat(KeystoreUtil.KeystoreFormat keystoreFormat) {
this.keystoreFormat = keystoreFormat;
return this;
@ -20,6 +26,12 @@ public class CertificatesConfigBuilder {
return this.keystoreFormat;
}
/**
* Enable TLS
*
* @param tlsEnabled <code>true</code> if tls should be enabled
* @return
*/
public CertificatesConfigBuilder tlsEnabled(boolean tlsEnabled) {
this.tlsEnabled = tlsEnabled;
return this;
@ -29,6 +41,12 @@ public class CertificatesConfigBuilder {
return tlsEnabled || mTlsEnabled;
}
/**
* Enable mTLS authentication between Keycloak and clients
*
* @param mTlsEnabled <code>true</code> if mTLS should be enabled
* @return
*/
public CertificatesConfigBuilder mTlsEnabled(boolean mTlsEnabled) {
this.mTlsEnabled = mTlsEnabled;
return this;

View file

@ -22,6 +22,9 @@ import org.keycloak.crypto.def.DefaultCryptoProvider;
import org.apache.http.conn.ssl.TrustAllStrategy;
import org.apache.http.ssl.SSLContextBuilder;
/**
* Utilities for Keycloak server and clients to obtain keystore and truststores with the managed certificates
*/
public class ManagedCertificates {
private final static Path KEYSTORES_DIR = Path.of(System.getProperty("java.io.tmpdir"));
@ -72,35 +75,68 @@ public class ManagedCertificates {
clientSslContext = tlsEnabled ? createClientSSLContext() : null;
}
/**
* The path of the generated Keycloak server keystore containing the private certificate for the Keycloak server
*
* @return path to keystore
*/
public String getServerKeyStorePath() {
return tlsEnabled ? serverKeystorePath.toString() : null;
}
/**
* The password used for the keystore
*
* @return keystore password
*/
public String getServerKeyStorePassword() {
return tlsEnabled ? STORE_PASSWORD : null;
}
/**
* The path of the generated Keycloak server truststore containing public certificates for clients
* @return
*/
public String getServerTrustStorePath() {
return mTlsEnabled ? serverTruststorePath.toString() : null;
}
/**
* The password used for the truststore
*
* @return truststore password
*/
public String getServerTrustStorePassword() {
return mTlsEnabled ? STORE_PASSWORD : null;
}
/**
* Return the SSL context configured with the client truststore
*
* @return
*/
public SSLContext getClientSSLContext() {
return clientSslContext;
}
/**
* Returns <code>true</code> if TLS is enabled
*
* @return <code>true</code> if TLS is enabled
*/
public boolean isTlsEnabled() {
return tlsEnabled;
}
/**
* Returns <code>true</code> if mTLS is enabled
*
* @return <code>true</code> if mTLS is enabled
*/
public boolean isMTlsEnabled() {
return mTlsEnabled;
}
private SSLContext createClientSSLContext() {
try {
SSLContextBuilder sslContextBuilder = SSLContextBuilder.create()

View file

@ -10,6 +10,10 @@ public abstract class ManagedTestResource {
return dirty;
}
/**
* Marking the resource as dirty will result in the test framework re-creating the resource after the test
* has executed
*/
public void dirty() {
this.dirty = true;
}

View file

@ -1,5 +1,8 @@
package org.keycloak.testframework.realm;
/**
* Declarative configuration for managed clients
*/
public interface ClientConfig {
ClientConfigBuilder configure(ClientConfigBuilder client);

View file

@ -4,6 +4,9 @@ import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.testframework.injection.ManagedTestResource;
/**
* Utilities to work with managed clients
*/
public class ManagedClient extends ManagedTestResource {
private final ClientRepresentation createdRepresentation;
@ -16,22 +19,46 @@ public class ManagedClient extends ManagedTestResource {
this.clientResource = clientResource;
}
/**
* The UUID of the client
* @return client UUID
*/
public String getId() {
return createdRepresentation.getId();
}
/**
* The clientId of the client
* @return client clientId
*/
public String getClientId() {
return createdRepresentation.getClientId();
}
/**
* The client secret if set
* @return client secret
*/
public String getSecret() {
return createdRepresentation.getSecret();
}
/**
* Admin client resource for the client to view or update the configuration of the client. Updates should in general
* not be done directly through the client resource as it will leave the client in a unexpected state for sub-sequent
* tests
*
* @return client resource
*/
public ClientResource admin() {
return clientResource;
}
/**
* Update the client within a test with automatic reset to the original configuration after the test has completed
*
* @param updates the update to the client
*/
public void updateWithCleanup(ManagedClient.ClientUpdate... updates) {
ClientRepresentation rep = admin().toRepresentation();

View file

@ -10,6 +10,12 @@ public class ManagedClientCleanup {
private final List<ClientCleanup> cleanupTasks = new LinkedList<>();
/**
* Add a cleanup to be done for the client after the test is completed
*
* @param clientCleanup the required cleanup
* @return
*/
public ManagedClientCleanup add(ClientCleanup clientCleanup) {
this.cleanupTasks.add(clientCleanup);
return this;

View file

@ -14,6 +14,9 @@ import org.keycloak.testframework.util.ApiUtil;
import org.junit.jupiter.api.Assertions;
/**
* Utilities to work with managed realms
*/
public class ManagedRealm extends ManagedTestResource {
private final String baseUrl;
@ -29,10 +32,20 @@ public class ManagedRealm extends ManagedTestResource {
this.realmResource = realmResource;
}
/**
* The base URL of the realm (for example <code>http://localhost:8080/realms/myrealm</code>)
*
* @return the realm base URL
*/
public String getBaseUrl() {
return baseUrl;
}
/**
* The UUID of the realm
*
* @return realm UUID
*/
public String getId() {
if (realmId == null && createdRepresentation.getId() != null) {
realmId = createdRepresentation.getId();
@ -42,18 +55,40 @@ public class ManagedRealm extends ManagedTestResource {
return realmId;
}
/**
* The name of the realm
*
* @return realm name
*/
public String getName() {
return createdRepresentation.getRealm();
}
/**
* Admin realm resource for the realm to view or update the configuration of the realm. Updates should in general
* not be done directly through the realm resource as it will leave the realm in an unexpected state for sub-sequent
* tests
*
* @return realm resource
*/
public RealmResource admin() {
return realmResource;
}
/**
* The representation used to create the realm
*
* @return realm representation
*/
public RealmRepresentation getCreatedRepresentation() {
return createdRepresentation;
}
/**
* Update the realm within a test with automatic reset to the original configuration after the test has completed
*
* @param updates the updates to the realm
*/
public void updateWithCleanup(RealmUpdate... updates) {
RealmRepresentation rep = admin().toRepresentation();
cleanup().resetToOriginalRepresentation(rep);
@ -66,12 +101,23 @@ public class ManagedRealm extends ManagedTestResource {
admin().update(configBuilder.build());
}
/**
* Add a user to the realm, which is automatically removed once the test is completed
*
* @param user the user to add
*/
public void addUser(UserConfigBuilder user) {
UserRepresentation rep = user.build();
String id = ApiUtil.getCreatedId(realmResource.users().create(rep));
cleanup().add(r -> r.users().get(id).remove());
}
/**
* Update a user within the realm, which is automatically reset once the test is completed
*
* @param username the username of the user to update
* @param update the update to perform on the user
*/
public void updateUser(String username, UserConfigBuilder.UserUpdate update) {
List<UserRepresentation> result = realmResource.users().search(username);
Assertions.assertEquals(1, result.size());
@ -84,6 +130,12 @@ public class ManagedRealm extends ManagedTestResource {
cleanup().add(r -> r.users().get(original.getId()).update(original));
}
/**
* Update an identity provider within the realm, which is automatically reset once the test is completed
*
* @param alias the alias of the identity provider to update
* @param update the update to perform on the identity provider
*/
public void updateIdentityProvider(String alias, IdentityProviderUpdate update) {
IdentityProviderResource resource = realmResource.identityProviders().get(alias);
@ -95,6 +147,12 @@ public class ManagedRealm extends ManagedTestResource {
cleanup().add(r -> r.identityProviders().get(alias).update(original));
}
/**
* Update a component within the realm, which is automatically reset once the test is completed
*
* @param id the id of the component to update
* @param update the update to perform on the component
*/
public void updateComponent(String id, ComponentUpdate update) {
ComponentResource componentResource = realmResource.components().component(id);

View file

@ -10,6 +10,12 @@ public class ManagedRealmCleanup {
private final List<RealmCleanup> cleanupTasks = new LinkedList<>();
/**
* Add a cleanup task to perform on the realm once the test has completed
*
* @param realmCleanup the cleanup to perform on the realm
* @return
*/
public ManagedRealmCleanup add(RealmCleanup realmCleanup) {
this.cleanupTasks.add(realmCleanup);
return this;

View file

@ -1,5 +1,8 @@
package org.keycloak.testframework.realm;
/**
* Declarative configuration for managed realms
*/
public interface RealmConfig {
RealmConfigBuilder configure(RealmConfigBuilder realm);

View file

@ -1,5 +1,8 @@
package org.keycloak.testframework.realm;
/**
* Declarative configuration for managed users
*/
public interface UserConfig {
UserConfigBuilder configure(UserConfigBuilder user);

View file

@ -1,5 +1,8 @@
package org.keycloak.testframework.server;
/**
* Declarative configuration for the managed Keycloak server
*/
public interface KeycloakServerConfig {
KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config);

View file

@ -38,21 +38,48 @@ public class KeycloakServerConfigBuilder {
return new KeycloakServerConfigBuilder("start-dev");
}
/**
* Set the client id and secret to use for bootstrapping configuration
*
* @param clientId the client id
* @param clientSecret the client secret
* @return
*/
public KeycloakServerConfigBuilder bootstrapAdminClient(String clientId, String clientSecret) {
return option("bootstrap-admin-client-id", clientId)
.option("bootstrap-admin-client-secret", clientSecret);
}
/**
* Set the username and password to use for bootstrapping configuration
*
* @param username the username
* @param password the secret
* @return
*/
public KeycloakServerConfigBuilder bootstrapAdminUser(String username, String password) {
return option("bootstrap-admin-username", username)
.option("bootstrap-admin-password", password);
}
/**
* Configure if local caches or clustered caches should be used. Using local caches results in a faster startup
* time
*
* @param cacheType
* @return
*/
public KeycloakServerConfigBuilder cache(CacheType cacheType) {
this.cacheType = cacheType;
return this;
}
/**
* Connect to a managed external Infinispan server
*
* @param enabled
* @return
*/
public KeycloakServerConfigBuilder externalInfinispanEnabled(boolean enabled) {
if (enabled) {
this.externalInfinispan = true;
@ -68,35 +95,82 @@ public class KeycloakServerConfigBuilder {
return this.externalInfinispan;
}
/**
* Configure logging
*
* @return
*/
public LogBuilder log() {
return log;
}
/**
* Enable the specified features. In most cases used to enable features that are not enabled by default
*
* @param features the features to enable
* @return
*/
public KeycloakServerConfigBuilder features(Profile.Feature... features) {
this.features.addAll(toFeatureStrings(features));
return this;
}
/**
* Disable the specified features. In most cases used to disable features that are enabled by default
*
* @param features the features to disable
* @return
*/
public KeycloakServerConfigBuilder featuresDisabled(Profile.Feature... features) {
this.featuresDisabled.addAll(toFeatureStrings(features));
return this;
}
/**
* Set multiple CLI options
*
* @param options
* @return
*/
public KeycloakServerConfigBuilder options(Map<String, String> options) {
this.options.putAll(options);
return this;
}
/**
* Set the specified CLI option
*
* @param key the key of the option
* @param value the value of the option
* @return
*/
public KeycloakServerConfigBuilder option(String key, String value) {
options.put(key, value);
return this;
}
/**
* Set an SPI configuration option
*
* @param spi the name of the SPI
* @param provider the name of the provider
* @param key the name of the option
* @param value the value to set
* @return
*/
public KeycloakServerConfigBuilder spiOption(String spi, String provider, String key, String value) {
options.put(String.format(SPI_OPTION, spi, provider, key), value);
return this;
}
/**
* Deploy a dependency to the server by specifying the Maven groupId and artifactId. The version is resolved from
* the project pom files
*
* @param groupId the Maven groupId of the dependency
* @param artifactId the Maven artifactId of the dependency
* @return
*/
public KeycloakServerConfigBuilder dependency(String groupId, String artifactId) {
dependencies.add(new DependencyBuilder().setGroupId(groupId).setArtifactId(artifactId).build());
return this;

View file

@ -16,38 +16,84 @@ public class KeycloakUrls {
this.managementBaseUrl = managementBaseUrl;
}
/**
* The base string representation of the URL of the Keycloak server (for example <code>http://localhost:8080</code>)
*
* @return the server base URL as a string
*/
public String getBase() {
return baseUrl;
}
/**
* The URL of the Keycloak server (for example <code>http://localhost:8080</code>)
*
* @return the server base URL
*/
public URL getBaseUrl() {
return toUrl(getBase());
}
/**
* The string representation of the base URL of the master realm
*
* @return
*/
public String getMasterRealm() {
return baseUrl + "/realms/master";
}
/**
* The URL of the master realm
*
* @return master realm URL
*/
public URL getMasterRealmUrl() {
return toUrl(getMasterRealm());
}
/**
* The string representation of the URL of Admin endpoints
*
* @return admin URL as a string
*/
public String getAdmin() {
return baseUrl + "/admin";
}
/**
* The URL of Admin endpoints
*
* @return admin URL
*/
public URL getAdminUrl() {
return toUrl(getAdmin());
}
/**
* Builder to resolve paths from the Keycloak server base URL
*
* @return base URL builder
*/
public KeycloakUriBuilder getBaseBuilder() {
return toBuilder(getBase());
}
/**
* Builder to resolve paths from the admin URL
*
* @return admin URL builder
*/
public KeycloakUriBuilder getAdminBuilder() {
return toBuilder(getAdmin());
}
/**
* String representation of the URL of the metrics endpoint
*
* @return metrics endpoint
*/
public String getMetric() {
return managementBaseUrl + "/metrics";
}

View file

@ -4,8 +4,20 @@ import jakarta.ws.rs.core.Response;
import org.junit.jupiter.api.Assertions;
/**
* Utilities for the Keycloak Java Admin client
*/
public class ApiUtil {
/**
* Several POST endpoints in Keycloak Admin API does not return the created resource in the response; but rather
* returns a location header instead, making it harder to get the generated ID of a newly created resource. This
* method parses the location header and returns the ID of the created resource, as well as closing the JAX-RS
* response.
*
* @param response the response from a POST request, for example creating a new user in a realm
* @return the ID of the created resource, for example the UUID of a new user
*/
public static String getCreatedId(Response response) {
try (response) {
Assertions.assertEquals(201, response.getStatus());

View file

@ -10,6 +10,10 @@ import com.icegreen.greenmail.user.TokenValidator;
import com.icegreen.greenmail.util.GreenMail;
import com.icegreen.greenmail.util.ServerSetup;
/**
* Retrieve emails sent by the Keycloak server. Received emails are reset when a test is executed, which means
* only emails sent during a test was executed are returned.
*/
public class MailServer extends ManagedTestResource {
private final GreenMail greenMail;
@ -36,19 +40,41 @@ public class MailServer extends ManagedTestResource {
((com.icegreen.greenmail.user.UserImpl)user).setTokenValidator(validator);
}
/**
* Retrieve all received emails
*
* @return list of received emails
*/
public MimeMessage[] getReceivedMessages() {
return greenMail.getReceivedMessages();
}
/**
* Retrieve the last received email
*
* @return the last received email
*/
public MimeMessage getLastReceivedMessage() {
MimeMessage[] receivedMessages = greenMail.getReceivedMessages();
return receivedMessages != null && receivedMessages.length > 0 ? receivedMessages[receivedMessages.length - 1] : null;
}
/**
* Wait for the specified time to receive the specified number of emails
*
* @param timeout the time to wait for emails to be received
* @param emailCount the number of emails to wait for
* @return
*/
public boolean waitForIncomingEmail(long timeout, int emailCount) {
return greenMail.waitForIncomingEmail(timeout, emailCount);
}
/**
*
* @param emailCount
* @return
*/
public boolean waitForIncomingEmail(int emailCount) {
return greenMail.waitForIncomingEmail(emailCount);
}

View file

@ -5,6 +5,9 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Injects a {@link org.keycloak.testframework.mail.MailServer} to receive emails sent by the Keycloak server
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectMailServer { }

View file

@ -11,6 +11,9 @@ import org.keycloak.testsuite.util.oauth.OAuthClientConfig;
import org.apache.http.impl.client.CloseableHttpClient;
import org.openqa.selenium.support.PageFactory;
/**
* OAuth client to send OAuth request and handle callbacks
*/
public class OAuthClient extends AbstractOAuthClient<OAuthClient> {
private final ManagedWebDriver managedWebDriver;

View file

@ -27,6 +27,9 @@ import com.sun.net.httpserver.HttpServer;
import static org.keycloak.common.crypto.CryptoConstants.EC_KEY_SECP256R1;
/**
* Mock identity provider that can be used to test various brokering flows
*/
public class OAuthIdentityProvider {
private final HttpServer httpServer;

View file

@ -2,6 +2,9 @@ package org.keycloak.testframework.oauth;
import com.sun.net.httpserver.HttpServer;
/**
* Mock OAuth client exposed on an HTTP server so Keycloak can send callbacks to the client
*/
public class TestApp {
public static final String OAUTH_CALLBACK_PATH = "/callback/oauth";

View file

@ -9,6 +9,9 @@ import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.oauth.DefaultOAuthClientConfiguration;
import org.keycloak.testframework.realm.ClientConfig;
/**
* Injects a {@link org.keycloak.testframework.oauth.OAuthClient} that can be used to send OAuth request to Keycloak
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectOAuthClient {

View file

@ -9,6 +9,10 @@ import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.oauth.DefaultOAuthIdentityProviderConfig;
import org.keycloak.testframework.oauth.OAuthIdentityProviderConfig;
/**
* Injects a {@link org.keycloak.testframework.oauth.OAuthIdentityProvider} that can be used to mock an identity
* provider
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectOAuthIdentityProvider {

View file

@ -5,6 +5,9 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Injects a {@link org.keycloak.testframework.oauth.TestApp} that can be used to mock an OAuth client
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectTestApp {

View file

@ -7,6 +7,10 @@ import java.lang.annotation.Target;
import org.keycloak.testframework.injection.LifeCycle;
/**
* Injects a {@link RunOnServerClient} to execute code within the Keycloak server. Classes are serialized and sent
* to the Keycloak server when needed
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectRunOnServer {

View file

@ -31,10 +31,26 @@ public class RunOnServerClient {
this.executionId = executionId;
}
/**
* Retrieve some value from the Keycloak server using the specified wrapper
*
* @param wrapper the wrapper containing the code and return type
* @return the value
* @param <T> the return type
* @throws RunOnServerException
*/
public <T> T fetch(FetchOnServerWrapper<T> wrapper) throws RunOnServerException {
return fetch(wrapper.getRunOnServer(), wrapper.getResultClass());
}
/**
* Retrieve some value from the Keycloak server using the specified function
* @param function the function to execute
* @param clazz the return type
* @return the value
* @param <T> the return type
* @throws RunOnServerException
*/
public <T> T fetch(FetchOnServer function, Class<T> clazz) throws RunOnServerException {
try {
String s = fetchString(function);
@ -44,6 +60,12 @@ public class RunOnServerClient {
}
}
/**
* Retrieve a string value from the Keycloak server using the specified function
* @param function the function to execute
* @return the value
* @throws RunOnServerException
*/
public String fetchString(FetchOnServer function) throws RunOnServerException {
String encoded = SerializationUtil.encode(function);
@ -60,6 +82,12 @@ public class RunOnServerClient {
}
}
/**
* Execute code on the Keycloak server, including assertions to verify values on the server side
*
* @param function the function to execute
* @throws RunOnServerException
*/
public void run(RunOnServer function) throws RunOnServerException {
String encoded = SerializationUtil.encode(function);
@ -74,7 +102,7 @@ public class RunOnServerClient {
}
}
public String runOnServer(String encoded) throws RunOnServerException {
private String runOnServer(String encoded) throws RunOnServerException {
try {
HttpPost request = new HttpPost(url + "?executionId=" + executionId);
request.setHeader("Content-type", "text/plain;charset=utf-8");

View file

@ -7,6 +7,9 @@ import java.lang.annotation.Target;
import org.keycloak.testframework.injection.LifeCycle;
/**
* Injects a {@link TimeOffSet} to change the timeoffset on the Keycloak server
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectTimeOffSet {

View file

@ -30,6 +30,12 @@ public class TimeOffSet {
currentOffset = initOffset;
}
/**
* Set the timeoffset on the Keycloak server
*
* @param offset the timeoffset
* @throws RuntimeException
*/
public void set(int offset) throws RuntimeException {
currentOffset = offset;
@ -57,6 +63,11 @@ public class TimeOffSet {
}
/**
* Retrive the current time offset
*
* @return the time offset
*/
public int get() {
return currentOffset;
}

View file

@ -5,6 +5,10 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Injects an implementation of {@link org.keycloak.testframework.ui.page.AbstractPage} to interact with HTML pages
* published by the Keycloak server
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectPage {

View file

@ -5,6 +5,10 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Inject a {@link org.keycloak.testframework.ui.webdriver.ManagedWebDriver} to interact directly with the web driver.
* When possible it is recommended to use pages instead of directly accessing the web driver.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectWebDriver { }