diff --git a/build.gradle.kts b/build.gradle.kts index 4e69887..9eabc4e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -62,6 +62,8 @@ dependencyManagement { } } +// https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3 +val mockitoAgent = configurations.create("mockitoAgent") dependencies { implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-webflux") @@ -79,9 +81,11 @@ dependencies { testImplementation("io.projectreactor:reactor-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + mockitoAgent("org.mockito:mockito-core") { isTransitive = false } } tasks.withType { + jvmArgs("-javaagent:${mockitoAgent.asPath}") useJUnitPlatform() // generate jacoco test report finalizedBy("jacocoTestReport") diff --git a/src/main/java/com/cleverthis/authservice/controller/rabbitmq/AbstractEndpoint.java b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/AbstractEndpoint.java index d68ccb2..db086bc 100644 --- a/src/main/java/com/cleverthis/authservice/controller/rabbitmq/AbstractEndpoint.java +++ b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/AbstractEndpoint.java @@ -14,7 +14,6 @@ import java.io.IOException; import java.io.InputStream; import java.util.Map; import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.RequestParam; /** * Abstract class for endpoint of auth-service over RabbitMQ. @@ -34,7 +33,7 @@ public abstract class AbstractEndpoint implements AutoCloseable { * Initialize this abstract class. */ public AbstractEndpoint( - @RequestParam final CleverMicroAmqpClient client, + final CleverMicroAmqpClient client, final ObjectMapper objectMapper, final String routingKey ) throws IOException { @@ -51,33 +50,38 @@ public abstract class AbstractEndpoint implements AutoCloseable { ); this.handlerTag = client.getHandlerManager().registerHandler( this.queueName, "", 1, - ctx -> new Thread(() -> { - try { - final var req = parseRequest(ctx); - if (req == null) { - throw new EndpointBadRequestException( - "Malformed request, cannot be parsed"); - } - handleRequest(ctx, req); - } catch (final EndpointException ex) { - final var resp = EndpointResponses.error(ex); - reply(ctx, resp); - } catch (final Throwable t) { - log.error( - "Error when handling request for queue {}, only refill availability", - this.queueName, t - ); - final var resp = EndpointResponses.internalServerError( - "Error when handling the request", t); - reply(ctx, resp); - } - }).start(), + ctx -> new Thread(() -> handleRabbitMessage(ctx)).start(), tag -> log.info("Endpoint canceled for queue {}: handlerTag={}", this.queueName, tag) ); } - private EndpointRequest parseRequest(final HandlerContext ctx) { + // VisibleForTesting + void handleRabbitMessage(final HandlerContext ctx) { + try { + final var req = parseRequest(ctx); + if (req == null) { + throw new EndpointBadRequestException( + "Malformed request, cannot be parsed"); + } + handleRequest(ctx, req); + } catch (final EndpointException ex) { + final var resp = EndpointResponses.error(ex); + reply(ctx, resp); + } catch (final Throwable t) { + log.error( + "Error when handling request for queue {}, only refill availability", + this.queueName, t + ); + final var resp = EndpointResponses.internalServerError( + "Error when handling the request", t); + reply(ctx, resp); + } + } + + + // VisibleForTesting + EndpointRequest parseRequest(final HandlerContext ctx) { InputStream reqStream = null; if (ctx.getMessageBodySingleStream().isPresent()) { reqStream = ctx.getMessageBodySingleStream().get(); diff --git a/src/main/java/com/cleverthis/authservice/service/impl/KeycloakAdminReactiveClient.java b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakAdminReactiveClient.java index 5fcec58..c2e9bc8 100644 --- a/src/main/java/com/cleverthis/authservice/service/impl/KeycloakAdminReactiveClient.java +++ b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakAdminReactiveClient.java @@ -6,14 +6,15 @@ import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest; import com.cleverthis.authservice.exception.RegistrationException; import com.cleverthis.authservice.exception.ServiceUnavailableException; import com.cleverthis.authservice.exception.UserAlreadyExistsException; +import jakarta.ws.rs.ClientErrorException; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.core.Response; import java.util.Collections; import java.util.List; import java.util.NoSuchElementException; +import java.util.function.Consumer; import lombok.extern.slf4j.Slf4j; import org.keycloak.admin.client.Keycloak; -import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.MemberRepresentation; @@ -21,6 +22,7 @@ import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoSink; /** * Service to interact with Keycloak Admin API. @@ -46,39 +48,6 @@ public class KeycloakAdminReactiveClient { } - /** - * Fetches the internal UUID of a Keycloak client given its public clientId. - * - * @param publicClientId The human-readable client_id (e.g., "cleverswarm-client"). - * @return Mono emitting the internal UUID string. - */ - public Mono getClientInternalId(String publicClientId) { - log.debug("Fetching internal ID for client: {}", publicClientId); - - return Mono.just(this.keycloak.realm(this.realm) - .clients().findByClientId(publicClientId)) - .flatMap(result -> { - if (result.isEmpty()) { - log.warn("Client id '{}' not found in Keycloak", publicClientId); - return Mono.error(new ServiceUnavailableException( - "Client not found in Keycloak: " + publicClientId)); - } else { - return Mono.just(result.getFirst()); - } - }) - .map(ClientRepresentation::getId) - .doOnSuccess(id -> log.debug("Found internal ID '{}' for client '{}'", id, - publicClientId)) - .onErrorMap(t -> { - log.error("Failed to get client '{}'", - publicClientId, t); - return new ServiceUnavailableException( - "Could not fetch client details from Keycloak, clientPublicId=" - + publicClientId, t); - }); - - } - /** * Creates a new organization in Keycloak. * @@ -125,8 +94,15 @@ public class KeycloakAdminReactiveClient { * @return A Mono emitting the representation of the found organization. */ public Mono getOrganizationById(String orgId) { - return Mono.just(this.keycloak.realm(this.realm) - .organizations().get(orgId).toRepresentation()); + return Mono.create(sink -> { + try { + sink.success(this.keycloak.realm(this.realm) + .organizations().get(orgId).toRepresentation()); + } catch (final NotFoundException ex) { + sink.error(new NoSuchElementException( + "Organization with id " + orgId + " not exists", ex)); + } + }); } /** @@ -153,8 +129,6 @@ public class KeycloakAdminReactiveClient { } else { sink.success(); } - } catch (final Throwable t) { - sink.error(t); } }); } @@ -179,8 +153,6 @@ public class KeycloakAdminReactiveClient { } else { sink.success(); } - } catch (final Throwable t) { - sink.error(t); } }); } @@ -211,8 +183,6 @@ public class KeycloakAdminReactiveClient { } else { sink.success(); } - } catch (final Throwable t) { - sink.error(t); } }); } @@ -224,9 +194,16 @@ public class KeycloakAdminReactiveClient { * @return A Mono emitting a List of user representations for the members. */ public Mono> getOrganizationMembers(String orgId) { - return Mono.just(this.keycloak.realm(this.realm) - .organizations().get(orgId) - .members().list(null, Integer.MAX_VALUE)); + return Mono.create(sink -> { + try { + sink.success(this.keycloak.realm(this.realm) + .organizations().get(orgId) + .members().list(null, Integer.MAX_VALUE)); + } catch (final NotFoundException ex) { + sink.error(new NoSuchElementException( + "Organization with id " + orgId + " not exists", ex)); + } + }); } /** @@ -253,8 +230,6 @@ public class KeycloakAdminReactiveClient { } else { sink.success(); } - } catch (final Throwable t) { - sink.error(t); } }); } @@ -281,8 +256,6 @@ public class KeycloakAdminReactiveClient { } else { sink.success(); } - } catch (final Throwable t) { - sink.error(t); } }); } @@ -310,8 +283,6 @@ public class KeycloakAdminReactiveClient { } else { sink.success(); } - } catch (final Throwable t) { - sink.error(t); } }); } @@ -323,8 +294,12 @@ public class KeycloakAdminReactiveClient { * @return A Mono emitting the group representation. */ public Mono getGroupById(String groupId) { - return Mono.just( - this.keycloak.realm(this.realm).groups().group(groupId).toRepresentation()); + return Mono.create((Consumer>) sink -> + sink.success(this.keycloak.realm(this.realm) + .groups().group(groupId).toRepresentation())) + .onErrorMap(NotFoundException.class, + ex -> new NoSuchElementException( + "Group with id " + groupId + " not exists", ex)); } /** @@ -334,9 +309,13 @@ public class KeycloakAdminReactiveClient { * @return A Mono emitting a list of sub-group representations. */ public Mono> listSubGroups(String parentGroupId) { - return Mono.just(this.keycloak.realm(this.realm) - .groups().group(parentGroupId) - .getSubGroups(null, Integer.MAX_VALUE, false)); + return Mono.create((Consumer>>) sink -> + sink.success(this.keycloak.realm(this.realm) + .groups().group(parentGroupId) + .getSubGroups(null, Integer.MAX_VALUE, false))) + .onErrorMap(NotFoundException.class, + ex -> new NoSuchElementException( + "Group with id " + parentGroupId + " not exists", ex)); } /** @@ -347,10 +326,14 @@ public class KeycloakAdminReactiveClient { * @return A Mono that completes on successful update. */ public Mono updateGroup(String groupId, GroupRepresentation group) { - return Mono.create(sink -> { - this.keycloak.realm(this.realm).groups().group(groupId).update(group); - sink.success(); - }); + return Mono + .create((Consumer>) sink -> { + this.keycloak.realm(this.realm).groups().group(groupId).update(group); + sink.success(); + }) + .onErrorMap(NotFoundException.class, ex -> new NoSuchElementException( + "Group with id " + groupId + " not exists" + )); } /** @@ -360,10 +343,14 @@ public class KeycloakAdminReactiveClient { * @return A Mono that completes on successful deletion. */ public Mono deleteGroup(String groupId) { - return Mono.create(sink -> { - this.keycloak.realm(this.realm).groups().group(groupId).remove(); - sink.success(); - }); + return Mono + .create((Consumer>) sink -> { + this.keycloak.realm(this.realm).groups().group(groupId).remove(); + sink.success(); + }) + .onErrorMap(NotFoundException.class, ex -> new NoSuchElementException( + "Group with id " + groupId + " not exists" + )); } /** @@ -375,10 +362,14 @@ public class KeycloakAdminReactiveClient { */ public Mono addMemberToGroup(String groupId, String userId) { // Keycloak API for adding a user to a group is a PUT on the user's groups link - return Mono.create(sink -> { - this.keycloak.realm(this.realm).users().get(userId).joinGroup(groupId); - sink.success(); - }); + return Mono + .create((Consumer>) sink -> { + this.keycloak.realm(this.realm).users().get(userId).joinGroup(groupId); + sink.success(); + }) + .onErrorMap(ClientErrorException.class, ex -> new IllegalArgumentException( + "Failed to add user with id " + userId + " to group with id " + groupId + )); } /** @@ -388,8 +379,11 @@ public class KeycloakAdminReactiveClient { * @return A Mono emitting a list of user representations. */ public Mono> getGroupMembers(String groupId) { - return Mono.just(this.keycloak.realm(this.realm).groups().group(groupId).members()); - + return Mono.create((Consumer>>) sink -> sink.success( + this.keycloak.realm(this.realm).groups().group(groupId).members())) + .onErrorMap(NotFoundException.class, ex -> new NoSuchElementException( + "Group with id " + groupId + " not exist", ex + )); } /** @@ -400,10 +394,14 @@ public class KeycloakAdminReactiveClient { * @return A Mono that completes when the user is removed from the group. */ public Mono removeMemberFromGroup(String groupId, String userId) { - return Mono.create(sink -> { - this.keycloak.realm(this.realm).users().get(userId).leaveGroup(groupId); - sink.success(); - }); + return Mono + .create((Consumer>) sink -> { + this.keycloak.realm(this.realm).users().get(userId).leaveGroup(groupId); + sink.success(); + }) + .onErrorMap(ClientErrorException.class, ex -> new IllegalArgumentException( + "Failed to remove user with id " + userId + " from group with id " + groupId + )); } /** @@ -455,8 +453,6 @@ public class KeycloakAdminReactiveClient { "User registration failed due to Keycloak error." + " Status: " + resp.getStatus())); } - } catch (final Throwable t) { - sink.error(t); } }); @@ -468,12 +464,10 @@ public class KeycloakAdminReactiveClient { * @throws NoSuchElementException in mono if keycloak returns 404. */ public Mono getUserDetails(String userId) { - try { - return Mono.just(this.keycloak.realm(this.realm) - .users().get(userId).toRepresentation()); - } catch (final NotFoundException ex) { - return Mono.error(new NoSuchElementException("User with id " + userId + " not exists")); - } + return Mono.create((Consumer>) sink -> sink.success( + this.keycloak.realm(this.realm).users().get(userId).toRepresentation())) + .onErrorMap(NotFoundException.class, + ex -> new NoSuchElementException("User with id " + userId + " not exists", ex)); } } \ No newline at end of file diff --git a/src/test/java/com/cleverthis/authservice/controller/rabbitmq/AbstractEndpointTest.java b/src/test/java/com/cleverthis/authservice/controller/rabbitmq/AbstractEndpointTest.java new file mode 100644 index 0000000..dbceb82 --- /dev/null +++ b/src/test/java/com/cleverthis/authservice/controller/rabbitmq/AbstractEndpointTest.java @@ -0,0 +1,238 @@ +package com.cleverthis.authservice.controller.rabbitmq; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException; +import com.cleverthis.authservice.controller.rabbitmq.model.EndpointErrorResponse; +import com.cleverthis.authservice.controller.rabbitmq.model.EndpointOkResponse; +import com.cleverthis.authservice.controller.rabbitmq.model.EndpointRequest; +import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient; +import com.cleverthis.clevermicro.client.amqp.handler.HandlerContext; +import com.cleverthis.clevermicro.client.amqp.handler.HandlerSubmodule; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rabbitmq.client.BuiltinExchangeType; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class AbstractEndpointTest { + private final CleverMicroAmqpClient client = Mockito.mock(CleverMicroAmqpClient.class); + final ObjectMapper objectMapper = Mockito.spy(new ObjectMapper()); + + private class TestEndpoint extends AbstractEndpoint { + + TestEndpoint() throws Exception { + super( + AbstractEndpointTest.this.client, + AbstractEndpointTest.this.objectMapper, + "test-key" + ); + } + + // The throws is needed for mocked object to throw exception during test + @SuppressWarnings("RedundantThrows") + @Override + protected void handleRequest( + final HandlerContext ctx, final EndpointRequest request + ) throws EndpointException { + } + } + + private final HandlerSubmodule handlerSubmodule = Mockito.mock(HandlerSubmodule.class); + + @BeforeEach + void setup() { + when(this.client.getHandlerManager()).thenReturn(this.handlerSubmodule); + } + + @Test + void testInitAndClose() throws Exception { + when(this.client.getHandlerManager().registerHandler( + anyString(), eq(""), eq(1), + notNull(), notNull() + )).thenReturn("test-tag"); + final var testEndpoint = new TestEndpoint(); + verify(this.client.getHandlerManager()).declareExchange( + "cleverthis.clevermicro.auth.endpoints", BuiltinExchangeType.DIRECT + ); + verify(this.client.getHandlerManager()).declareQueue( + "cleverthis.clevermicro.auth.endpoints.test-key", + true, false, false, Map.of() + ); + verify(this.client.getHandlerManager()).bindQueue( + "cleverthis.clevermicro.auth.endpoints.test-key", + "cleverthis.clevermicro.auth.endpoints", + "test-key", Map.of() + ); + + testEndpoint.close(); + verify(this.client.getHandlerManager()).cancelHandler("test-tag"); + } + + @Test + void testHandler() throws Throwable { + final var testEndpoint = Mockito.spy(new TestEndpoint()); + final var handlerContext = Mockito.mock(HandlerContext.class); + + // silent reply method, it will be tested separately + doNothing().when(testEndpoint).reply(eq(handlerContext), notNull()); + + // parse req null + doReturn(null).when(testEndpoint).parseRequest(notNull()); + testEndpoint.handleRabbitMessage(handlerContext); + verify(testEndpoint).reply(eq(handlerContext), argThat(it -> { + final var resp = (EndpointErrorResponse) it; + return resp.getException().equals("cleverthis.clevermicro.bad_request") + && resp.getMessage().equals("Malformed request, cannot be parsed"); + })); + + // path: parse req ok + Mockito.clearInvocations(testEndpoint); + // handle req ok + doReturn(EndpointRequest.builder().build()).when(testEndpoint).parseRequest(notNull()); + doNothing().when(testEndpoint).handleRequest(notNull(), notNull()); + assertDoesNotThrow(() -> testEndpoint.handleRabbitMessage(handlerContext)); + verify(testEndpoint, never()).reply(notNull(), notNull()); + + // handle req endpoint exception + Mockito.clearInvocations(testEndpoint); + doThrow(new EndpointException("test", "message")) + .when(testEndpoint).handleRequest(notNull(), notNull()); + assertDoesNotThrow(() -> testEndpoint.handleRabbitMessage(handlerContext)); + verify(testEndpoint).reply(notNull(), argThat(it -> { + final var resp = (EndpointErrorResponse) it; + return resp.getException().equals("test") + && resp.getMessage().equals("message"); + })); + + // handle req any exception + Mockito.clearInvocations(testEndpoint); + doThrow(new RuntimeException("test")) + .when(testEndpoint).handleRequest(notNull(), notNull()); + assertDoesNotThrow(() -> testEndpoint.handleRabbitMessage(handlerContext)); + verify(testEndpoint).reply(notNull(), argThat(it -> { + final var resp = (EndpointErrorResponse) it; + return resp.getException().equals("cleverthis.clevermicro.server_internal_error"); + })); + } + + @Test + void testParseRequest_SingleStream() throws Exception { + //noinspection resource + final var testEndpoint = new TestEndpoint(); + final var handlerContext = Mockito.mock(HandlerContext.class); + final var inputStream = Mockito.mock(InputStream.class); + + when(handlerContext.getMessageBodySingleStream()).thenReturn(Optional.of(inputStream)); + final var req = EndpointRequest.builder().method("testMethod").build(); + doReturn(req).when(this.objectMapper).readValue(inputStream, EndpointRequest.class); + + final var result = testEndpoint.parseRequest(handlerContext); + assertEquals(req, result); + } + + @Test + void testParseRequest_MultiStream() throws Exception { + //noinspection resource + final var testEndpoint = new TestEndpoint(); + final var handlerContext = Mockito.mock(HandlerContext.class); + final var inputStream = Mockito.mock(InputStream.class); + + when(handlerContext.getMessageBodyMultiStream()).thenReturn(Optional.of( + Map.of("request", inputStream) + )); + final var req = EndpointRequest.builder().method("testMethod").build(); + doReturn(req).when(this.objectMapper).readValue(inputStream, EndpointRequest.class); + + final var result = testEndpoint.parseRequest(handlerContext); + assertEquals(req, result); + } + + @Test + void testReply_SuccessfulSerialization() throws Exception { + //noinspection resource + final var testEndpoint = new TestEndpoint(); + final var handlerContext = Mockito.mock(HandlerContext.class); + final var response = Map.of("key", "value"); + + assertDoesNotThrow(() -> testEndpoint.reply(handlerContext, response)); + verify(handlerContext).finish(""" + { + "key" : "value" + } + """.trim().getBytes(StandardCharsets.UTF_8)); + } + + @Test + void testReply_SerializationFailure() throws Exception { + //noinspection resource + final var testEndpoint = new TestEndpoint(); + final var handlerContext = Mockito.mock(HandlerContext.class); + final var response = mock(EndpointOkResponse.class); + // throw error when accessing fields, causing jackson failed to serialize + when(response.getResult()).thenThrow(new RuntimeException("test")); + + assertDoesNotThrow(() -> testEndpoint.reply(handlerContext, response)); + verify(handlerContext, never()).finish(any()); + verify(handlerContext).refillAvailability(); + } + + @Test + void testReply_ReplyFailure() throws Exception { + //noinspection resource + final var testEndpoint = new TestEndpoint(); + final var handlerContext = Mockito.mock(HandlerContext.class); + final var response = Map.of("key", "value"); + + doThrow(new IOException("Reply error")).when(handlerContext).finish(notNull()); + + assertDoesNotThrow(() -> testEndpoint.reply(handlerContext, response)); + } + + @Test + void testParseRequest_InvalidRequest() throws Exception { + //noinspection resource + final var testEndpoint = new TestEndpoint(); + final var handlerContext = Mockito.mock(HandlerContext.class); + final var inputStream = Mockito.mock(InputStream.class); + + when(handlerContext.getMessageBodySingleStream()).thenReturn(Optional.of(inputStream)); + doThrow(new IOException("test")).when(this.objectMapper) + .readValue(inputStream, EndpointRequest.class); + + final var result = testEndpoint.parseRequest(handlerContext); + assertNull(result); + } + + @Test + void testParseRequest_NoStream() throws Exception { + //noinspection resource + final var testEndpoint = new TestEndpoint(); + final var handlerContext = Mockito.mock(HandlerContext.class); + + when(handlerContext.getMessageBodySingleStream()).thenReturn(Optional.empty()); + when(handlerContext.getMessageBodyMultiStream()).thenReturn(Optional.empty()); + + final var result = testEndpoint.parseRequest(handlerContext); + assertNull(result); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleverthis/authservice/service/impl/KeycloakAdminReactiveClientTest.java b/src/test/java/com/cleverthis/authservice/service/impl/KeycloakAdminReactiveClientTest.java index 5650c33..927c95f 100644 --- a/src/test/java/com/cleverthis/authservice/service/impl/KeycloakAdminReactiveClientTest.java +++ b/src/test/java/com/cleverthis/authservice/service/impl/KeycloakAdminReactiveClientTest.java @@ -1,25 +1,974 @@ package com.cleverthis.authservice.service.impl; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import com.cleverthis.authservice.config.KeycloakClientConfigProperties; +import com.cleverthis.authservice.dto.RegistrationRequest; +import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest; +import com.cleverthis.authservice.exception.RegistrationException; +import com.cleverthis.authservice.exception.ServiceUnavailableException; +import com.cleverthis.authservice.exception.UserAlreadyExistsException; +import jakarta.ws.rs.ClientErrorException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.ProcessingException; +import jakarta.ws.rs.core.Response; +import java.util.List; +import java.util.NoSuchElementException; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.MemberRepresentation; +import org.keycloak.representations.idm.OrganizationRepresentation; +import org.keycloak.representations.idm.UserRepresentation; import org.mockito.Mockito; class KeycloakAdminReactiveClientTest { private KeycloakAdminReactiveClient adminClient; + private final Keycloak keycloak = mock(Keycloak.class); @BeforeEach void setup() { - final var keycloak = Mockito.mock(Keycloak.class); + when(this.keycloak.realm("test-realm")).thenReturn(mock()); + + when(this.keycloak.realm("test-realm").organizations()).thenReturn(mock()); + when(this.keycloak.realm("test-realm").groups()).thenReturn(mock()); + when(this.keycloak.realm("test-realm").users()).thenReturn(mock()); + final var configProperties = KeycloakClientConfigProperties.builder() - .keycloakAdminHost("http://example.com/") .realm("test-realm") - .adminAccountUsername("admin") - .adminAccountPassword("admin-password") .build(); - this.adminClient = Mockito.spy(new KeycloakAdminReactiveClient(keycloak, configProperties)); + this.adminClient = + Mockito.spy(new KeycloakAdminReactiveClient(this.keycloak, configProperties)); } - // TODO: implement tests + @Test + void testCreateOrganization_Success() { + // Arrange + final var orgToCreate = new OrganizationRepresentation(); + orgToCreate.setId("test-id"); + orgToCreate.setName("Test Organization"); + + final var response = mock(Response.class); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()) + .thenReturn(Response.Status.Family.SUCCESSFUL); + when(response.readEntity(OrganizationRepresentation.class)).thenReturn(orgToCreate); + + when(this.keycloak.realm("test-realm") + .organizations().create(orgToCreate)) + .thenReturn(response); + + // Act + final var result = this.adminClient.createOrganization(orgToCreate).block(); + + // Assert + assertNotNull(result); + assertEquals("Test Organization", result.getName()); + //noinspection resource + verify(this.keycloak.realm("test-realm").organizations()).create(orgToCreate); + } + + @Test + void testCreateOrganization_Failure() { + // Arrange + final var orgToCreate = new OrganizationRepresentation(); + orgToCreate.setId("test-id"); + orgToCreate.setName("Test Organization"); + + final var response = mock(Response.class); + when(response.getStatusInfo()).thenReturn(mock()); + + // keycloak return non-200 + when(response.getStatusInfo().getFamily()) + .thenReturn(Response.Status.Family.CLIENT_ERROR); + when(response.readEntity(String.class)).thenReturn("Error occurred"); + + when( + this.keycloak.realm("test-realm").organizations() + .create(orgToCreate)) + .thenReturn(response); + + // Act & Assert + Assertions.assertThrows(ServiceUnavailableException.class, + () -> this.adminClient.createOrganization(orgToCreate).block()); + + + // client read exception + when(response.getStatusInfo().getFamily()) + .thenReturn(Response.Status.Family.SUCCESSFUL); + when(response.readEntity(OrganizationRepresentation.class)) + .thenThrow(new ProcessingException("test")); + + when( + this.keycloak.realm("test-realm").organizations() + .create(orgToCreate)) + .thenReturn(response); + + // Act & Assert + Assertions.assertThrows(ProcessingException.class, + () -> this.adminClient.createOrganization(orgToCreate).block()); + } + + @Test + void testGetOrganizations_Success() { + // Arrange + final var organization1 = new OrganizationRepresentation(); + organization1.setId("org-1"); + organization1.setName("Organization 1"); + + final var organization2 = new OrganizationRepresentation(); + organization2.setId("org-2"); + organization2.setName("Organization 2"); + + when(this.keycloak.realm("test-realm") + .organizations().list(null, Integer.MAX_VALUE)) + .thenReturn(List.of(organization1, organization2)); + + // Act + final var result = this.adminClient.getOrganizations().block(); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals("Organization 1", result.get(0).getName()); + assertEquals("Organization 2", result.get(1).getName()); + verify(this.keycloak.realm("test-realm").organizations()).list(null, Integer.MAX_VALUE); + } + + @Test + void testGetOrganizationById_Success() { + // Arrange + final var organization = new OrganizationRepresentation(); + organization.setId("org-1"); + organization.setName("Test Organization"); + + when(this.keycloak.realm("test-realm") + .organizations().get("org-1")) + .thenReturn(mock()); + when(this.keycloak.realm("test-realm") + .organizations().get("org-1").toRepresentation()) + .thenReturn(organization); + + // Act + final var result = this.adminClient.getOrganizationById("org-1").block(); + + // Assert + assertNotNull(result); + assertEquals("org-1", result.getId()); + assertEquals("Test Organization", result.getName()); + verify(this.keycloak.realm("test-realm").organizations().get("org-1")) + .toRepresentation(); + } + + @Test + void testGetOrganizationById_NotFound() { + // Arrange + when(this.keycloak.realm("test-realm") + .organizations().get("non-existing-id")) + .thenReturn(mock()); + when(this.keycloak.realm("test-realm") + .organizations().get("non-existing-id").toRepresentation()) + .thenThrow(new NotFoundException("Organization not found")); + + // Act & Assert + Assertions.assertThrows(NoSuchElementException.class, + () -> this.adminClient.getOrganizationById("non-existing-id").block()); + verify(this.keycloak.realm("test-realm").organizations().get("non-existing-id")) + .toRepresentation(); + } + + @Test + void testUpdateOrganization_Success() { + // Arrange + final var orgId = "org-1"; + final var orgToUpdate = new OrganizationRepresentation(); + orgToUpdate.setId("org-1"); + orgToUpdate.setName("Updated Organization"); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).update(orgToUpdate)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL); + + // Act + this.adminClient.updateOrganization(orgId, orgToUpdate).block(); + + // Assert + //noinspection resource + verify(this.keycloak.realm("test-realm").organizations().get(orgId)).update(orgToUpdate); + } + + @Test + void testUpdateOrganization_Failure_ClientError() { + // Arrange + final var orgId = "org-1"; + final var orgToUpdate = new OrganizationRepresentation(); + orgToUpdate.setId("org-1"); + orgToUpdate.setName("Updated Organization"); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).update(orgToUpdate)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR); + when(response.readEntity(String.class)).thenReturn("Client error occurred"); + + // Act & Assert + Assertions.assertThrows(ServiceUnavailableException.class, + () -> this.adminClient.updateOrganization(orgId, orgToUpdate).block()); + } + + @Test + void testUpdateOrganization_Failure_ProcessingException() { + // Arrange + final var orgId = "org-1"; + final var orgToUpdate = new OrganizationRepresentation(); + orgToUpdate.setId("org-1"); + orgToUpdate.setName("Updated Organization"); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).update(orgToUpdate)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR); + when(response.readEntity(String.class)) + .thenThrow(new ProcessingException("Processing error")); + + // Act & Assert + Assertions.assertThrows(ProcessingException.class, + () -> this.adminClient.updateOrganization(orgId, orgToUpdate).block()); + } + + @Test + void testDeleteOrganization_Success() { + // Arrange + final var orgId = "org-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).delete()) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL); + + // Act + this.adminClient.deleteOrganization(orgId).block(); + + // Assert + //noinspection resource + verify(this.keycloak.realm("test-realm").organizations().get(orgId)).delete(); + } + + @Test + void testDeleteOrganization_Failure_ClientError() { + // Arrange + final var orgId = "org-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).delete()) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR); + when(response.readEntity(String.class)).thenReturn("Client error occurred"); + + // Act & Assert + Assertions.assertThrows(ServiceUnavailableException.class, + () -> this.adminClient.deleteOrganization(orgId).block()); + } + + @Test + void testDeleteOrganization_Failure_ServerError() { + // Arrange + final var orgId = "org-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).delete()) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR); + when(response.readEntity(String.class)).thenReturn("Server error occurred"); + + // Act & Assert + Assertions.assertThrows(ServiceUnavailableException.class, + () -> this.adminClient.deleteOrganization(orgId).block()); + } + + @Test + void testInviteUserToOrganization_Success() { + // Arrange + final var orgId = "org-1"; + final var invitation = new KeycloakInvitationRequest("test@example.com", "Test", "User"); + final var response = mock(Response.class); + + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .inviteUser(invitation.getEmail(), invitation.getFirstName(), + invitation.getLastName())) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL); + + // Act + this.adminClient.inviteUserToOrganization(orgId, invitation).block(); + + // Assert + //noinspection resource + verify(this.keycloak.realm("test-realm").organizations().get(orgId).members()) + .inviteUser(invitation.getEmail(), invitation.getFirstName(), + invitation.getLastName()); + } + + @Test + void testInviteUserToOrganization_Failure_ClientError() { + // Arrange + final var orgId = "org-1"; + final var invitation = new KeycloakInvitationRequest("test@example.com", "Test", "User"); + final var response = mock(Response.class); + + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .inviteUser(invitation.getEmail(), invitation.getFirstName(), + invitation.getLastName())) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR); + when(response.readEntity(String.class)).thenReturn("Client error occurred"); + + // Act & Assert + Assertions.assertThrows(ServiceUnavailableException.class, + () -> this.adminClient.inviteUserToOrganization(orgId, invitation).block()); + } + + @Test + void testInviteUserToOrganization_Failure_Exception() { + // Arrange + final var orgId = "org-1"; + final var invitation = new KeycloakInvitationRequest("test@example.com", "Test", "User"); + final var response = mock(Response.class); + + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .inviteUser(invitation.getEmail(), invitation.getFirstName(), + invitation.getLastName())) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR); + when(response.readEntity(String.class)).thenThrow(new RuntimeException("Unexpected error")); + + // Act & Assert + Assertions.assertThrows(RuntimeException.class, + () -> this.adminClient.inviteUserToOrganization(orgId, invitation).block()); + } + + @Test + void testGetOrganizationMembers_Success() { + // Arrange + final var orgId = "org-1"; + + final var member1 = new MemberRepresentation(); + member1.setId("user-1"); + member1.setUsername("member1"); + + final var member2 = new MemberRepresentation(); + member2.setId("user-2"); + member2.setUsername("member2"); + + when(this.keycloak.realm("test-realm").organizations().get(orgId)) + .thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()) + .thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId) + .members().list(null, Integer.MAX_VALUE)) + .thenReturn(List.of(member1, member2)); + + // Act + final var result = this.adminClient.getOrganizationMembers(orgId).block(); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals("user-1", result.get(0).getId()); + assertEquals("user-2", result.get(1).getId()); + verify(this.keycloak.realm("test-realm").organizations().get(orgId) + .members()).list(null, Integer.MAX_VALUE); + } + + @Test + void testGetOrganizationMembers_Failure_NotFound() { + // Arrange + final var orgId = "non-existing-org"; + + when(this.keycloak.realm("test-realm").organizations().get(orgId)) + .thenThrow(new NotFoundException("Organization not found")); + + // Act & Assert + Assertions.assertThrows(NoSuchElementException.class, + () -> this.adminClient.getOrganizationMembers(orgId).block()); + verify(this.keycloak.realm("test-realm").organizations()).get(orgId); + } + + @Test + void testAddMemberToOrganization_Success() { + // Arrange + final var orgId = "org-1"; + final var userId = "user-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .addMember(userId)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL); + + // Act + this.adminClient.addMemberToOrganization(orgId, userId).block(); + + // Assert + //noinspection resource + verify(this.keycloak.realm("test-realm").organizations().get(orgId).members()) + .addMember(userId); + } + + @Test + void testAddMemberToOrganization_Failure_ClientError() { + // Arrange + final var orgId = "org-1"; + final var userId = "user-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .addMember(userId)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR); + when(response.readEntity(String.class)).thenReturn("Client error occurred"); + + // Act & Assert + Assertions.assertThrows(ServiceUnavailableException.class, + () -> this.adminClient.addMemberToOrganization(orgId, userId).block()); + } + + @Test + void testAddMemberToOrganization_Failure_UnexpectedException() { + // Arrange + final var orgId = "org-1"; + final var userId = "user-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .addMember(userId)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR); + when(response.readEntity(String.class)).thenThrow(new RuntimeException("Unexpected error")); + + // Act & Assert + Assertions.assertThrows(RuntimeException.class, + () -> this.adminClient.addMemberToOrganization(orgId, userId).block()); + } + + @Test + void testRemoveMemberFromOrganization_Success() { + // Arrange + final var orgId = "org-1"; + final var userId = "user-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .removeMember(userId)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL); + + // Act + this.adminClient.removeMemberFromOrganization(orgId, userId).block(); + + // Assert + //noinspection resource + verify(this.keycloak.realm("test-realm").organizations().get(orgId).members()) + .removeMember(userId); + } + + @Test + void testRemoveMemberFromOrganization_Failure_ClientError() { + // Arrange + final var orgId = "org-1"; + final var userId = "user-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .removeMember(userId)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR); + when(response.readEntity(String.class)).thenReturn("Client error occurred"); + + // Act & Assert + Assertions.assertThrows(ServiceUnavailableException.class, + () -> this.adminClient.removeMemberFromOrganization(orgId, userId).block()); + } + + @Test + void testRemoveMemberFromOrganization_Failure_Exception() { + // Arrange + final var orgId = "org-1"; + final var userId = "user-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .removeMember(userId)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR); + when(response.readEntity(String.class)).thenThrow(new RuntimeException("Unexpected error")); + + // Act & Assert + Assertions.assertThrows(RuntimeException.class, + () -> this.adminClient.removeMemberFromOrganization(orgId, userId).block()); + } + + @Test + void testCreateSubGroup_Success() { + // Arrange + final var parentGroupId = "parent-group-1"; + final var subGroup = new GroupRepresentation(); + subGroup.setName("Sub Group 1"); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").groups().group(parentGroupId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").groups().group(parentGroupId).subGroup(subGroup)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL); + + // Act + this.adminClient.createSubGroup(parentGroupId, subGroup).block(); + + // Assert + //noinspection resource + verify(this.keycloak.realm("test-realm").groups().group(parentGroupId)).subGroup(subGroup); + } + + @Test + void testCreateSubGroup_Failure_ClientError() { + // Arrange + final var parentGroupId = "parent-group-1"; + final var subGroup = new GroupRepresentation(); + subGroup.setName("Sub Group 1"); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").groups().group(parentGroupId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").groups().group(parentGroupId).subGroup(subGroup)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR); + when(response.readEntity(String.class)).thenReturn("Client error occurred"); + + // Act & Assert + Assertions.assertThrows(ServiceUnavailableException.class, + () -> this.adminClient.createSubGroup(parentGroupId, subGroup).block()); + } + + @Test + void testCreateSubGroup_Failure_Exception() { + // Arrange + final var parentGroupId = "parent-group-1"; + final var subGroup = new GroupRepresentation(); + subGroup.setName("Sub Group 1"); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").groups().group(parentGroupId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").groups().group(parentGroupId).subGroup(subGroup)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR); + when(response.readEntity(String.class)).thenThrow(new RuntimeException("Unexpected error")); + + // Act & Assert + Assertions.assertThrows(RuntimeException.class, + () -> this.adminClient.createSubGroup(parentGroupId, subGroup).block()); + } + + @Test + void testGetGroupById_Success() { + // Arrange + final var groupId = "group-1"; + final var group = new GroupRepresentation(); + group.setId(groupId); + group.setName("Test Group"); + + when(this.keycloak.realm("test-realm").groups().group(groupId)) + .thenReturn(mock()); + when(this.keycloak.realm("test-realm").groups().group(groupId).toRepresentation()) + .thenReturn(group); + + // Act + final var result = this.adminClient.getGroupById(groupId).block(); + + // Assert + assertNotNull(result); + assertEquals("group-1", result.getId()); + assertEquals("Test Group", result.getName()); + verify(this.keycloak.realm("test-realm").groups().group(groupId)).toRepresentation(); + } + + @Test + void testGetGroupById_NotFound() { + // Arrange + final var groupId = "non-existing-group"; + + when(this.keycloak.realm("test-realm").groups().group(groupId)) + .thenReturn(mock()); + when(this.keycloak.realm("test-realm").groups().group(groupId).toRepresentation()) + .thenThrow(new NotFoundException("Group not found")); + + // Act & Assert + Assertions.assertThrows(NoSuchElementException.class, + () -> this.adminClient.getGroupById(groupId).block()); + verify(this.keycloak.realm("test-realm").groups().group(groupId)).toRepresentation(); + } + + @Test + void testListSubGroups_Success() { + // Arrange + final var parentGroupId = "parent-group-1"; + + final var subGroup1 = new GroupRepresentation(); + subGroup1.setId("sub-group-1"); + subGroup1.setName("Sub Group 1"); + + final var subGroup2 = new GroupRepresentation(); + subGroup2.setId("sub-group-2"); + subGroup2.setName("Sub Group 2"); + + when(this.keycloak.realm("test-realm").groups().group(parentGroupId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").groups().group(parentGroupId) + .getSubGroups(null, Integer.MAX_VALUE, false)) + .thenReturn(List.of(subGroup1, subGroup2)); + + // Act + final var result = this.adminClient.listSubGroups(parentGroupId).block(); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals("sub-group-1", result.get(0).getId()); + assertEquals("sub-group-2", result.get(1).getId()); + verify(this.keycloak.realm("test-realm").groups().group(parentGroupId)) + .getSubGroups(null, Integer.MAX_VALUE, false); + } + + @Test + void testListSubGroups_NotFound() { + // Arrange + final var parentGroupId = "non-existing-group"; + + when(this.keycloak.realm("test-realm").groups().group(parentGroupId)) + .thenThrow(new NotFoundException("Group not found")); + + // Act & Assert + Assertions.assertThrows(NoSuchElementException.class, + () -> this.adminClient.listSubGroups(parentGroupId).block()); + verify(this.keycloak.realm("test-realm").groups()).group(parentGroupId); + } + + @Test + void testUpdateGroup_Success() { + // Arrange + final var groupId = "group-1"; + final var groupToUpdate = new GroupRepresentation(); + groupToUpdate.setId("group-1"); + groupToUpdate.setName("Updated Group"); + + when(this.keycloak.realm("test-realm").groups().group(groupId)).thenReturn(mock()); + + // Act + this.adminClient.updateGroup(groupId, groupToUpdate).block(); + + // Assert + verify(this.keycloak.realm("test-realm").groups().group(groupId)).update(groupToUpdate); + } + + @Test + void testUpdateGroup_NotFound() { + // Arrange + final var groupId = "non-existing-group"; + final var groupToUpdate = new GroupRepresentation(); + groupToUpdate.setId("non-existing-group"); + groupToUpdate.setName("Non-existing Group"); + + when(this.keycloak.realm("test-realm").groups().group(groupId)) + .thenThrow(new NotFoundException("Group not found")); + + // Act & Assert + Assertions.assertThrows(NoSuchElementException.class, + () -> this.adminClient.updateGroup(groupId, groupToUpdate).block()); + verify(this.keycloak.realm("test-realm").groups()).group(groupId); + } + + @Test + void testDeleteGroup_Success() { + // Arrange + final var groupId = "group-1"; + when(this.keycloak.realm("test-realm").groups().group(groupId)).thenReturn(mock()); + + // Act + this.adminClient.deleteGroup(groupId).block(); + + // Assert + verify(this.keycloak.realm("test-realm").groups().group(groupId)).remove(); + } + + @Test + void testDeleteGroup_NotFound() { + // Arrange + final var groupId = "non-existing-group"; + when(this.keycloak.realm("test-realm").groups().group(groupId)) + .thenThrow(new NotFoundException("Group not found")); + + // Act & Assert + Assertions.assertThrows(NoSuchElementException.class, + () -> this.adminClient.deleteGroup(groupId).block()); + verify(this.keycloak.realm("test-realm").groups()).group(groupId); + } + + @Test + void testAddMemberToGroup_Success() { + // Arrange + final var groupId = "group-1"; + final var userId = "user-1"; + + when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(mock()); + + // Act + this.adminClient.addMemberToGroup(groupId, userId).block(); + + // Assert + verify(this.keycloak.realm("test-realm").users().get(userId)).joinGroup(groupId); + } + + @Test + void testAddMemberToGroup_ClientError() { + // Arrange + final var groupId = "group-1"; + final var userId = "user-1"; + + final var userResource = mock(UserResource.class); + when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(userResource); + doThrow(new ClientErrorException(404)).when(userResource).joinGroup(groupId); + + // Act & Assert + Assertions.assertThrows(IllegalArgumentException.class, + () -> this.adminClient.addMemberToGroup(groupId, userId).block()); + } + + @Test + void testGetGroupMembers_Success() { + // Arrange + final var groupId = "group-1"; + + final var member1 = new UserRepresentation(); + member1.setId("user-1"); + member1.setUsername("member1"); + + final var member2 = new UserRepresentation(); + member2.setId("user-2"); + member2.setUsername("member2"); + + when(this.keycloak.realm("test-realm").groups().group(groupId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").groups().group(groupId).members()) + .thenReturn(List.of(member1, member2)); + + // Act + final var result = this.adminClient.getGroupMembers(groupId).block(); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals("user-1", result.get(0).getId()); + assertEquals("user-2", result.get(1).getId()); + verify(this.keycloak.realm("test-realm").groups().group(groupId)).members(); + } + + @Test + void testGetGroupMembers_GroupNotFound() { + // Arrange + final var groupId = "non-existing-group"; + + when(this.keycloak.realm("test-realm").groups().group(groupId)) + .thenThrow(new NotFoundException("Group not found")); + + // Act & Assert + Assertions.assertThrows(NoSuchElementException.class, + () -> this.adminClient.getGroupMembers(groupId).block()); + verify(this.keycloak.realm("test-realm").groups()).group(groupId); + } + + @Test + void testRemoveMemberFromGroup_Success() { + // Arrange + final var groupId = "group-1"; + final var userId = "user-1"; + + when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(mock()); + + // Act + this.adminClient.removeMemberFromGroup(groupId, userId).block(); + + // Assert + verify(this.keycloak.realm("test-realm").users().get(userId)).leaveGroup(groupId); + } + + @Test + void testRemoveMemberFromGroup_Failure_ClientError() { + // Arrange + final var groupId = "group-1"; + final var userId = "user-1"; + + final var userResource = mock(UserResource.class); + when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(userResource); + doThrow(new ClientErrorException(404)).when(userResource).leaveGroup(groupId); + + // Act & Assert + Assertions.assertThrows(IllegalArgumentException.class, + () -> this.adminClient.removeMemberFromGroup(groupId, userId).block()); + } + + @Test + void testRegisterUser_Success() { + // Arrange + final var registrationRequest = new RegistrationRequest( + "John", "Doe", "john@example.com", "password123" + ); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").users().create(any(UserRepresentation.class))) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL); + + // Act + this.adminClient.registerUser(registrationRequest).block(); + + // Assert + //noinspection resource + verify(this.keycloak.realm("test-realm").users()) + .create(argThat(user -> user.getUsername().equals("john@example.com"))); + } + + @Test + void testRegisterUser_UserAlreadyExists() { + // Arrange + final var registrationRequest = new RegistrationRequest( + "John", "Doe", "john@example.com", "password123" + ); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").users().create(any(UserRepresentation.class))) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR); + when(response.getStatus()).thenReturn(409); + + // Act & Assert + Assertions.assertThrows(UserAlreadyExistsException.class, + () -> this.adminClient.registerUser(registrationRequest).block()); + } + + @Test + void testRegisterUser_ServerError() { + // Arrange + final var registrationRequest = new RegistrationRequest( + "John", "Doe", "john@example.com", "password123" + ); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").users().create(any(UserRepresentation.class))) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR); + when(response.readEntity(String.class)).thenReturn("Internal Server Error"); + + // Act & Assert + Assertions.assertThrows(RegistrationException.class, + () -> this.adminClient.registerUser(registrationRequest).block()); + } + + @Test + void testGetUserDetails_Success() { + // Arrange + final var userId = "user-1"; + final var userRepresentation = new UserRepresentation(); + userRepresentation.setId(userId); + userRepresentation.setUsername("user1"); + + when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").users().get(userId).toRepresentation()) + .thenReturn(userRepresentation); + + // Act + final var result = this.adminClient.getUserDetails(userId).block(); + + // Assert + assertNotNull(result); + assertEquals("user-1", result.getId()); + assertEquals("user1", result.getUsername()); + verify(this.keycloak.realm("test-realm").users().get(userId)).toRepresentation(); + } + + @Test + void testGetUserDetails_UserNotFound() { + // Arrange + final var userId = "non-existing-user"; + + when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").users().get(userId).toRepresentation()) + .thenThrow(new NotFoundException("User not found")); + + // Act & Assert + Assertions.assertThrows(NoSuchElementException.class, + () -> this.adminClient.getUserDetails(userId).block()); + verify(this.keycloak.realm("test-realm").users().get(userId)).toRepresentation(); + } } \ No newline at end of file