diff --git a/src/main/java/com/cleverthis/authservice/controller/rabbitmq/v1/user/UserEndpointV1.java b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/v1/user/UserEndpointV1.java index 9392f6c..b8b8a56 100644 --- a/src/main/java/com/cleverthis/authservice/controller/rabbitmq/v1/user/UserEndpointV1.java +++ b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/v1/user/UserEndpointV1.java @@ -57,7 +57,6 @@ public class UserEndpointV1 extends AbstractEndpoint { } // this should be non-null final var id = argId.textValue(); - assert id != null; try { return this.keycloakAdminClient.getUserDetails(id).block(); } catch (final NoSuchElementException ex) { diff --git a/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java index 9f0d05b..96838b1 100644 --- a/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java +++ b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java @@ -262,8 +262,7 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { return domain; }) .collect(Collectors.toSet()); - newOrg.getDomains().clear(); - newOrg.getDomains().addAll(domainRepresentations); + domainRepresentations.forEach(newOrg::addDomain); } return this.keycloakAdminClient.createOrganization(newOrg) .map(this::toOrganizationResponse); @@ -291,15 +290,17 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { // Update domains based on the new list of names if (request.getDomains() != null) { Set domainRepresentations = request.getDomains() - .stream() - .map(domainName -> { - final var domain = new OrganizationDomainRepresentation(domainName); - domain.setVerified(false); - return domain; - }) - .collect(Collectors.toSet()); - existingOrg.getDomains().clear(); - existingOrg.getDomains().addAll(domainRepresentations); + .stream() + .map(domainName -> { + final var domain = new OrganizationDomainRepresentation(domainName); + domain.setVerified(false); + return domain; + }) + .collect(Collectors.toSet()); + if (existingOrg.getDomains() != null) { + existingOrg.getDomains().clear(); + } + domainRepresentations.forEach(existingOrg::addDomain); } return this.keycloakAdminClient.updateOrganization(orgId, existingOrg); }); @@ -337,8 +338,9 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { } @Override - public Mono createSubGroup(final String parentGroupId, - final CreateGroupRequest request) { + public Mono createSubGroup( + final String parentGroupId, final CreateGroupRequest request + ) { GroupRepresentation newGroup = new GroupRepresentation(); newGroup.setName(request.getName()); newGroup.setAttributes(request.getAttributes()); diff --git a/src/test/java/com/cleverthis/authservice/controller/rabbitmq/v1/user/UserEndpointV1Test.java b/src/test/java/com/cleverthis/authservice/controller/rabbitmq/v1/user/UserEndpointV1Test.java new file mode 100644 index 0000000..a184301 --- /dev/null +++ b/src/test/java/com/cleverthis/authservice/controller/rabbitmq/v1/user/UserEndpointV1Test.java @@ -0,0 +1,119 @@ +package com.cleverthis.authservice.controller.rabbitmq.v1.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointBadRequestException; +import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException; +import com.cleverthis.authservice.controller.rabbitmq.model.EndpointRequest; +import com.cleverthis.authservice.controller.rabbitmq.model.EndpointResponses; +import com.cleverthis.authservice.service.impl.KeycloakAdminReactiveClient; +import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient; +import com.cleverthis.clevermicro.client.amqp.handler.HandlerContext; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.NoSuchElementException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.keycloak.representations.idm.UserRepresentation; +import reactor.core.publisher.Mono; + +class UserEndpointV1Test { + private final CleverMicroAmqpClient client = mock(); + private final ObjectMapper objectMapper = spy(new ObjectMapper()); + private final KeycloakAdminReactiveClient keycloakAdminClient = mock(); + + private UserEndpointV1 userEndpointV1; + + @BeforeEach + void setup() throws IOException { + when(this.client.getHandlerManager()).thenReturn(mock()); + this.userEndpointV1 = spy(new UserEndpointV1( + this.client, this.objectMapper, this.keycloakAdminClient)); + } + + @Test + void testHandler_MethodNotFound() { + final var req = mock(EndpointRequest.class); + when(req.getMethod()).thenReturn("method that doesn't exist"); + + final var ex = assertThrows(EndpointBadRequestException.class, + () -> this.userEndpointV1.handleRequest(mock(), req)); + + assertEquals("Method not found", ex.getMessage()); + } + + private EndpointRequest createRequest(String method, Map args) { + final String json; + try { + json = this.objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(args); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + final LinkedHashMap convertedArgs; + try { + convertedArgs = this.objectMapper.readValue(json, new TypeReference<>() { + }); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + return EndpointRequest.builder().method(method).args(convertedArgs).build(); + } + + @Test + void testHandler_QueryUserById() throws Exception { + final var handlerContext = mock(HandlerContext.class); + + // normal path + var req = createRequest("queryUserById", Map.of("id", "test-id")); + final var returnedUser = new UserRepresentation(); + returnedUser.setId("test-id"); + returnedUser.setUsername("test-username"); + when(this.keycloakAdminClient.getUserDetails("test-id")) + .thenReturn(Mono.just(returnedUser)); + + this.userEndpointV1.handleRequest(handlerContext, req); + + verify(handlerContext).finish( + this.objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes( + EndpointResponses.ok(returnedUser) + ) + ); + + // missing args + req = createRequest("queryUserById", Map.of("not-id", "test-id")); + { // scope for final copy + final var finalReq = req; + assertThrows(EndpointBadRequestException.class, + () -> this.userEndpointV1.handleRequest(handlerContext, finalReq)); + } + + // invalid args + req = createRequest("queryUserById", Map.of("id", 123)); + { // scope for final copy + final var finalReq = req; + assertThrows(EndpointBadRequestException.class, + () -> this.userEndpointV1.handleRequest(handlerContext, finalReq)); + } + + // user not found + when(this.keycloakAdminClient.getUserDetails("test-id")) + .thenReturn(Mono.error(new NoSuchElementException("test exception"))); + req = createRequest("queryUserById", Map.of("id", "test-id")); + { // scope for final copy + final var finalReq = req; + final var ex = assertThrows(EndpointException.class, + () -> this.userEndpointV1.handleRequest(handlerContext, finalReq)); + assertEquals("cleverthis.clevermicro.auth.user_not_found", ex.getEndpointException()); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerServiceTest.java b/src/test/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerServiceTest.java index b9bb52f..a2ce6da 100644 --- a/src/test/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerServiceTest.java +++ b/src/test/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerServiceTest.java @@ -1,23 +1,36 @@ package com.cleverthis.authservice.service.impl; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; import com.cleverthis.authservice.config.KeycloakClientConfigProperties; import com.cleverthis.authservice.dto.TokenResponse; import com.cleverthis.authservice.dto.VerificationResponse; +import com.cleverthis.authservice.dto.group.CreateGroupRequest; +import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest; +import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest; +import com.cleverthis.authservice.dto.organization.InviteUserRequest; +import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest; import com.cleverthis.authservice.exception.AuthenticationException; import com.cleverthis.authservice.exception.TokenVerificationException; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; +import java.util.List; +import java.util.Map; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.MemberRepresentation; +import org.keycloak.representations.idm.OrganizationDomainRepresentation; +import org.keycloak.representations.idm.OrganizationRepresentation; import org.mockito.Mockito; import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; class KeycloakIdentityManagerServiceTest { @@ -246,4 +259,303 @@ class KeycloakIdentityManagerServiceTest { .expectNext(false) .verifyComplete(); } + + @Test + void createOrganization_success() { + // Arrange + final var request = new CreateOrganizationRequest( + "Test Organization", List.of("test.org"), "", Map.of() + ); + final var mockResponse = new OrganizationRepresentation(); + mockResponse.setId("123"); + mockResponse.setName("Test Organization"); + mockResponse.addDomain(new OrganizationDomainRepresentation("test.org")); + mockResponse.setDescription(""); + mockResponse.setEnabled(true); + + Mockito.when(this.adminClient.createOrganization(any())) + .thenReturn(Mono.just(mockResponse)); + + // Act + final var result = this.service.createOrganization(request); + + // Assert + StepVerifier.create(result) + .expectNextMatches(response -> "123".equals(response.getId()) && + "Test Organization".equals(response.getName())) + .verifyComplete(); + } + + @Test + void getOrganizations_success() { + // Arrange + final var mockOrganizations = List.of( + new OrganizationRepresentation() {{ + setId("org1"); + setName("Organization 1"); + addDomain(new OrganizationDomainRepresentation("domain1.org")); + }}, + new OrganizationRepresentation() {{ + setId("org2"); + setName("Organization 2"); + addDomain(new OrganizationDomainRepresentation("domain2.org")); + }} + ); + + Mockito.when(this.adminClient.getOrganizations()) + .thenReturn(Mono.just(mockOrganizations)); + + // Act + final var result = this.service.getOrganizations(); + + // Assert + StepVerifier.create(result) + .expectNextMatches(orgList -> orgList.size() == 2 + && "Organization 1".equals(orgList.getFirst().getName()) + && "domain1.org".equals( + orgList.getFirst().getDomains().iterator().next().getName()) + && "Organization 2".equals(orgList.get(1).getName())) + .verifyComplete(); + } + + @Test + void getOrganizationById_success() { + // Arrange + final var orgId = "abc123"; + final var orgRepresentation = new OrganizationRepresentation(); + orgRepresentation.setId(orgId); + orgRepresentation.setName("Test Organization"); + orgRepresentation.addDomain(new OrganizationDomainRepresentation("test.org")); + orgRepresentation.setDescription("Test Description"); + orgRepresentation.setEnabled(true); + + Mockito.when(this.adminClient.getOrganizationById(orgId)) + .thenReturn(Mono.just(orgRepresentation)); + + // Act + final var result = this.service.getOrganizationById(orgId); + + // Assert + StepVerifier.create(result) + .expectNextMatches(response -> "abc123".equals(response.getId()) + && "Test Organization".equals(response.getName()) + && "Test Description".equals(response.getDescription()) + && Boolean.TRUE.equals(response.getEnabled()) + && response.getDomains().iterator().next().getName().equals("test.org")) + .verifyComplete(); + } + + @Test + void updateOrganization_success() { + // Arrange + final var orgId = "org123"; + final var updateRequest = new UpdateOrganizationRequest( + List.of("updated.org"), "Updated Description", Map.of("key", List.of("value")) + ); + final var existingOrg = new OrganizationRepresentation(); + existingOrg.setId(orgId); + existingOrg.setDescription("Old Description"); + existingOrg.addDomain(new OrganizationDomainRepresentation("old.org")); + + Mockito.when(this.adminClient.getOrganizationById(orgId)) + .thenReturn(Mono.just(existingOrg)); + Mockito.when(this.adminClient.updateOrganization(orgId, existingOrg)) + .thenReturn(Mono.empty()); + + // Act + final var result = this.service.updateOrganization(orgId, updateRequest); + + // Assert + StepVerifier.create(result) + .verifyComplete(); + + Mockito.verify(this.adminClient).updateOrganization(orgId, existingOrg); + assertEquals("Updated Description", existingOrg.getDescription()); + assertEquals("value", existingOrg.getAttributes().get("key").getFirst()); + assertEquals(1, existingOrg.getDomains().size()); + assertEquals("updated.org", existingOrg.getDomains().iterator().next().getName()); + } + + @Test + void deleteOrganization_success() { + // Arrange + final var orgId = "test-org-id"; + + Mockito.when(this.adminClient.deleteOrganization(orgId)) + .thenReturn(Mono.empty()); + + // Act + final var result = this.service.deleteOrganization(orgId); + + // Assert + StepVerifier.create(result) + .verifyComplete(); + + Mockito.verify(this.adminClient).deleteOrganization(orgId); + } + + @Test + void inviteUserToOrganization_success() { + // Arrange + final var orgId = "test-org-id"; + final var request = new InviteUserRequest("test@example.com", "John", "Wick"); + final var invitationRequest = new KeycloakInvitationRequest(); + invitationRequest.setEmail(request.getEmail()); + invitationRequest.setFirstName(request.getFirstName()); + invitationRequest.setLastName(request.getLastName()); + + Mockito.when(this.adminClient.inviteUserToOrganization(any(), any())) + .thenReturn(Mono.empty()); + + // Act + final var result = this.service.inviteUserToOrganization(orgId, request); + + // Assert + StepVerifier.create(result) + .verifyComplete(); + + Mockito.verify(this.adminClient).inviteUserToOrganization(orgId, invitationRequest); + } + + @Test + void getOrganizationMembers_success() { + // Arrange + final var orgId = "test-org-id"; + final var mockMembers = List.of( + // I asked Gemini, and this is an antipattern that no one mentions + // when I learned Java. Basically, it will create a new anonymous + // class for every instance. In this case, we have two. + // Gemini suggests we use a method to create a user and then put it in the list, + // but a dedicated method is not as compact as double brace initialization. + // This test is generated by gemini 2.5 pro. I think it's ok to use here + // because it's a unit test, and I blame keycloak to not offer a builder. + // DO NOT use this pattern in the main source set. + new MemberRepresentation() {{ + setId("user1"); + setUsername("user1Username"); + setFirstName("User1"); + setLastName("One"); + setEmail("user1@example.com"); + setEnabled(true); + }}, + new MemberRepresentation() {{ + setId("user2"); + setUsername("user2Username"); + setFirstName("User2"); + setLastName("Two"); + setEmail("user2@example.com"); + setEnabled(true); + }} + ); + + Mockito.when(this.adminClient.getOrganizationMembers(orgId)) + .thenReturn(Mono.just(mockMembers)); + + // Act + final var result = this.service.getOrganizationMembers(orgId); + + // Assert + StepVerifier.create(result) + .expectNextMatches(members -> members.size() == 2 + && "user1".equals(members.getFirst().getId()) + && "user1@example.com".equals(members.getFirst().getEmail()) + && "user2".equals(members.get(1).getId()) + && "user2@example.com".equals(members.get(1).getEmail())) + .verifyComplete(); + + Mockito.verify(this.adminClient).getOrganizationMembers(orgId); + } + + @Test + void addMemberToOrganization_success() { + // Arrange + final var orgId = "test-org-id"; + final var userId = "test-user-id"; + + Mockito.when(this.adminClient.addMemberToOrganization(orgId, userId)) + .thenReturn(Mono.empty()); + + // Act + final var result = this.service.addMemberToOrganization(orgId, userId); + + // Assert + StepVerifier.create(result) + .verifyComplete(); + + Mockito.verify(this.adminClient).addMemberToOrganization(orgId, userId); + } + + @Test + void removeMemberFromOrganization_success() { + // Arrange + final var orgId = "test-org-id"; + final var userId = "test-user-id"; + + Mockito.when(this.adminClient.removeMemberFromOrganization(orgId, userId)) + .thenReturn(Mono.empty()); + + // Act + final var result = this.service.removeMemberFromOrganization(orgId, userId); + + // Assert + StepVerifier.create(result) + .verifyComplete(); + + Mockito.verify(this.adminClient).removeMemberFromOrganization(orgId, userId); + } + + @Test + void createSubGroup_success() { + // Arrange + final var parentGroupId = "parent-group-id"; + final var request = new CreateGroupRequest( + "Test SubGroup", "", Map.of("key", List.of("value"))); + final var mockGroupRepresentation = new GroupRepresentation(); + mockGroupRepresentation.setId("sub-group-id"); + mockGroupRepresentation.setName(request.getName()); + mockGroupRepresentation.setAttributes(request.getAttributes()); + + Mockito.when(this.adminClient.createSubGroup(parentGroupId, mockGroupRepresentation)) + .thenReturn(Mono.empty()); + + // Act + final var result = this.service.createSubGroup(parentGroupId, request); + + // Assert + StepVerifier.create(result) + .expectNextMatches(response -> "Test SubGroup".equals(response.getName()) && + response.getAttributes().get("key").contains("value")) + .verifyComplete(); + + Mockito.verify(this.adminClient).createSubGroup(Mockito.eq(parentGroupId), Mockito.any()); + } + + @Test + void getGroupById_success() { + // Arrange + final var groupId = "test-group-id"; + final var mockGroup = new GroupRepresentation(); + mockGroup.setId(groupId); + mockGroup.setName("Test Group"); + mockGroup.setPath("/test-group"); + mockGroup.setDescription("Test Group Description"); + mockGroup.setAttributes(Map.of("key", List.of("value1", "value2"))); + + Mockito.when(this.adminClient.getGroupById(groupId)) + .thenReturn(Mono.just(mockGroup)); + + // Act + final var result = this.service.getGroupById(groupId); + + // Assert + StepVerifier.create(result) + .expectNextMatches(response -> response.getId().equals(groupId) && + response.getName().equals("Test Group") && + response.getPath().equals("/test-group") && + response.getDescription().equals("Test Group Description") && + response.getAttributes().get("key").contains("value1")) + .verifyComplete(); + + Mockito.verify(this.adminClient).getGroupById(groupId); + } } \ No newline at end of file