diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 7708d440..8b4214a3 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -12,7 +12,8 @@ steps: agents: java: 11 commands: - - ./gradlew build --console plain + - pkill java || echo 'No Java processes to kill.' + - ./gradlew startServer && ./gradlew build --console plain - label: ':sonarqube: Quality reporting' <<: *if-our-repo @@ -20,7 +21,8 @@ steps: agents: java: 11 commands: - - ./gradlew clean codeCoverageReport sonarqube --console plain + - pkill java || echo 'No Java processes to kill.' + - ./gradlew clean startServer && ./gradlew codeCoverageReport sonarqube --console plain - wait: <<: *if-release @@ -29,7 +31,7 @@ steps: <<: *if-release key: bintray-publish agents: - java: 11 + java: 11 commands: - git fetch --tags - ./gradlew bintrayPublish bintrayUpload --console plain diff --git a/build.gradle b/build.gradle index e6ebe614..21bd6b08 100644 --- a/build.gradle +++ b/build.gradle @@ -73,7 +73,9 @@ subprojects { h2Version = '1.4.200' hamcrestVersion = '2.2' junitVersion = '5.7.2' + keycloakTestServerVersion = '15.0.2' mockitoVersion = '3.11.2' + undercouchVersion = '4.1.2' } dependencyManagement { diff --git a/src/api/src/main/java/engineering/everest/lhotse/api/rest/controllers/UserController.java b/src/api/src/main/java/engineering/everest/lhotse/api/rest/controllers/UserController.java index 10464009..f1118ab5 100644 --- a/src/api/src/main/java/engineering/everest/lhotse/api/rest/controllers/UserController.java +++ b/src/api/src/main/java/engineering/everest/lhotse/api/rest/controllers/UserController.java @@ -51,8 +51,8 @@ public UserResponse getUser(@ApiIgnore Principal principal) { @PutMapping @ApiOperation("Update currently authenticated user information") public void updateUser(@ApiIgnore Principal principal, @RequestBody UpdateUserRequest updateUserRequest) { - var uId = UUID.fromString(principal.getName()); - usersService.updateUser(uId, uId, updateUserRequest.getEmail(), + var userId = UUID.fromString(principal.getName()); + usersService.updateUser(userId, userId, updateUserRequest.getEmail(), updateUserRequest.getDisplayName()); } diff --git a/src/api/src/test/java/engineering/everest/lhotse/api/config/TestApiConfig.java b/src/api/src/test/java/engineering/everest/lhotse/api/config/TestApiConfig.java index 5f011191..d67bc800 100644 --- a/src/api/src/test/java/engineering/everest/lhotse/api/config/TestApiConfig.java +++ b/src/api/src/test/java/engineering/everest/lhotse/api/config/TestApiConfig.java @@ -125,7 +125,7 @@ public boolean belongsToOrg(UUID organizationId) { @Override public void setFilterObject(Object o) { - // Todo + // Do nothing } @Override @@ -135,7 +135,7 @@ public Object getFilterObject() { @Override public void setReturnObject(Object returnObject) { - // Todo + // Do nothing } @Override diff --git a/src/api/src/test/java/engineering/everest/lhotse/api/rest/controllers/OrganizationsControllerTest.java b/src/api/src/test/java/engineering/everest/lhotse/api/rest/controllers/OrganizationsControllerTest.java index 036b728d..d5189f85 100644 --- a/src/api/src/test/java/engineering/everest/lhotse/api/rest/controllers/OrganizationsControllerTest.java +++ b/src/api/src/test/java/engineering/everest/lhotse/api/rest/controllers/OrganizationsControllerTest.java @@ -200,16 +200,6 @@ void creatingOrganizationUser_WillFail_WhenDisplayNameIsBlank() throws Exception verifyNoInteractions(usersService); } - @Test - void creatingOrganizationUser_WillFail_WhenPasswordIsBlank() throws Exception { - mockMvc.perform(post("/api/organizations/{organizationId}/users", ORGANIZATION_2.getId()) - .contentType(APPLICATION_JSON) - .content(objectMapper.writeValueAsString(new NewUserRequest(ORG_1_USER_1.getUsername(), ORG_1_USER_1.getDisplayName())))) - .andExpect(status().isBadRequest()); - - verifyNoInteractions(usersService); - } - @Test @WithMockKeycloakAuth(authorities = ROLE_ADMIN) void creatingOrganizationUserWillDelegate_WhenRequestingUserIsAdmin() throws Exception { diff --git a/src/axon-support/src/main/java/engineering/everest/lhotse/axon/CommandValidatingMessageHandlerInterceptor.java b/src/axon-support/src/main/java/engineering/everest/lhotse/axon/CommandValidatingMessageHandlerInterceptor.java index 10d3047d..c34240d0 100644 --- a/src/axon-support/src/main/java/engineering/everest/lhotse/axon/CommandValidatingMessageHandlerInterceptor.java +++ b/src/axon-support/src/main/java/engineering/everest/lhotse/axon/CommandValidatingMessageHandlerInterceptor.java @@ -36,7 +36,7 @@ public CommandValidatingMessageHandlerInterceptor(List validators, Va Map, Validates> m = new ConcurrentHashMap<>(); for (Validates validator : validators) { Type validatableCommandType = Arrays.stream(validator.getClass().getGenericInterfaces()) - .map(e -> (ParameterizedType) e) + .map(ParameterizedType.class::cast) .filter(e -> Validates.class == e.getRawType()) .map(e -> e.getActualTypeArguments()[0]) .findFirst().orElseThrow(); diff --git a/src/axon-support/src/main/java/engineering/everest/lhotse/axon/replay/ReplayEndpoint.java b/src/axon-support/src/main/java/engineering/everest/lhotse/axon/replay/ReplayEndpoint.java index e0308e6a..7d19b741 100644 --- a/src/axon-support/src/main/java/engineering/everest/lhotse/axon/replay/ReplayEndpoint.java +++ b/src/axon-support/src/main/java/engineering/everest/lhotse/axon/replay/ReplayEndpoint.java @@ -113,8 +113,8 @@ private boolean isReplaying() { private List getReplayableEventProcessors() { return axonConfiguration.eventProcessingConfiguration().eventProcessors().values().stream() - .filter(e -> e instanceof ReplayableEventProcessor) - .map(e -> (ReplayableEventProcessor) e) + .filter(ReplayableEventProcessor.class::isInstance) + .map(ReplayableEventProcessor.class::cast) .collect(toList()); } diff --git a/src/common/src/main/java/engineering/everest/lhotse/axon/common/exceptions/KeycloakSynchronizationException.java b/src/common/src/main/java/engineering/everest/lhotse/axon/common/exceptions/KeycloakSynchronizationException.java new file mode 100644 index 00000000..0f5f1094 --- /dev/null +++ b/src/common/src/main/java/engineering/everest/lhotse/axon/common/exceptions/KeycloakSynchronizationException.java @@ -0,0 +1,7 @@ +package engineering.everest.lhotse.axon.common.exceptions; + +public class KeycloakSynchronizationException extends RuntimeException { + public KeycloakSynchronizationException(String message) { + super(message); + } +} diff --git a/src/common/src/main/java/engineering/everest/lhotse/axon/common/services/KeycloakSynchronizationService.java b/src/common/src/main/java/engineering/everest/lhotse/axon/common/services/KeycloakSynchronizationService.java index 1552ed9a..6d2841b5 100644 --- a/src/common/src/main/java/engineering/everest/lhotse/axon/common/services/KeycloakSynchronizationService.java +++ b/src/common/src/main/java/engineering/everest/lhotse/axon/common/services/KeycloakSynchronizationService.java @@ -1,9 +1,18 @@ package engineering.everest.lhotse.axon.common.services; +import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; +import engineering.everest.lhotse.axon.common.domain.Role; +import engineering.everest.lhotse.axon.common.domain.UserAttribute; +import engineering.everest.lhotse.axon.common.exceptions.KeycloakSynchronizationException; +import lombok.extern.slf4j.Slf4j; import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder; +import org.json.JSONArray; +import org.json.JSONObject; import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.KeycloakBuilder; @@ -14,10 +23,12 @@ import reactor.core.publisher.Mono; +@Slf4j @Component public class KeycloakSynchronizationService { private static final String BEARER = "Bearer "; private static final String AUTHORIZATION = "Authorization"; + private static final String VALUE_KEY = "value"; @Value("${keycloak.auth-server-url}") private String keycloakServerAuthUrl; @@ -26,13 +37,15 @@ public class KeycloakSynchronizationService { @Value("${kc.server.admin-password}") private String keycloakAdminPassword; @Value("${kc.server.master-realm.default.client-id}") - private String keycloakAdminClientId; + private String keycloakMasterRealmAdminClientId; + @Value("${keycloak.resource}") + private String keycloakDefaultRealmDefaultClientId; @Value("${kc.server.connection.pool-size}") private int keycloakServerConnectionPoolSize; private Keycloak getAdminKeycloakClientInstance() { return KeycloakBuilder.builder().serverUrl(keycloakServerAuthUrl).grantType(OAuth2Constants.PASSWORD) - .realm("master").clientId(keycloakAdminClientId).username(keycloakAdminUser) + .realm("master").clientId(keycloakMasterRealmAdminClientId).username(keycloakAdminUser) .password(keycloakAdminPassword) .resteasyClient(new ResteasyClientBuilder().connectionPoolSize(keycloakServerConnectionPoolSize).build()).build(); } @@ -67,6 +80,49 @@ public String getUsers(Map queryFilters) { var usersUri = String.format("%s/admin/realms/default/users", keycloakServerAuthUrl); var accessToken = BEARER + getAdminKeycloakClientInstance().tokenManager().getAccessToken().getToken(); + usersUri += getFilters(queryFilters); + return WebClient.create(usersUri).get().header(AUTHORIZATION, accessToken).retrieve().bodyToMono(String.class) + .block(); + } + + public String getClientDetails(Map queryFilters) { + var clientsUri = String.format("%s/admin/realms/default/clients", keycloakServerAuthUrl); + var accessToken = BEARER + getAdminKeycloakClientInstance().tokenManager().getAccessToken().getToken(); + + clientsUri += getFilters(queryFilters); + return WebClient.create(clientsUri).get().header(AUTHORIZATION, accessToken).retrieve().bodyToMono(String.class) + .block(); + } + + public String getClientLevelRoleMappings(UUID userId, UUID clientId) { + var clientLevelAvailableRolesUri = + String.format("%s/admin/realms/default/users/%s/role-mappings/clients/%s/available", + keycloakServerAuthUrl, userId, clientId); + var accessToken = BEARER + getAdminKeycloakClientInstance().tokenManager().getAccessToken().getToken(); + + return WebClient.create(clientLevelAvailableRolesUri).get().header(AUTHORIZATION, accessToken).retrieve().bodyToMono(String.class) + .block(); + } + + public void updateUserRoles(UUID userId, UUID clientId, List> roles) { + var userClientRolesMappingUri = + String.format("%s/admin/realms/default/users/%s/role-mappings/clients/%s", keycloakServerAuthUrl, userId, clientId); + var accessToken = BEARER + getAdminKeycloakClientInstance().tokenManager().getAccessToken().getToken(); + + WebClient.create(userClientRolesMappingUri).post().header(AUTHORIZATION, accessToken).contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON).bodyValue(roles) + .exchangeToMono(res -> Mono.just(res.statusCode())).block(); + } + + public String getClientSecret(UUID clientId) { + var clientSecretUri = String.format("%s/admin/realms/default/clients/%s/client-secret", keycloakServerAuthUrl, clientId); + var accessToken = BEARER + getAdminKeycloakClientInstance().tokenManager().getAccessToken().getToken(); + + return WebClient.create(clientSecretUri).get().header(AUTHORIZATION, accessToken).retrieve().bodyToMono(String.class) + .block(); + } + + private StringBuilder getFilters(Map queryFilters) { var filters = new StringBuilder("?"); if (!queryFilters.isEmpty()) { for (var filter : queryFilters.entrySet()) { @@ -75,11 +131,51 @@ public String getUsers(Map queryFilters) { filters.append(filter.getValue()); filters.append('&'); } - usersUri += filters; } - - return WebClient.create(usersUri).get().header(AUTHORIZATION, accessToken).retrieve().bodyToMono(String.class) - .block(); + return filters; } + public Map setupKeycloakUser(String username, String email, boolean enabled, UUID organizationId, + Set roles, String displayName, String password, boolean passwordTemporary) { + var userDetails = new HashMap(); + try { + createUser(Map.of("username", username, + "email", email, + "enabled", enabled, + "attributes", new UserAttribute(organizationId, roles, displayName), + "credentials", List.of(Map.of("type", "password", + VALUE_KEY, password, + "temporary", passwordTemporary)))); + + var userId = UUID.fromString(new JSONArray(getUsers(Map.of("username", username))) + .getJSONObject(0).getString("id")); + userDetails.put("userId", userId); + + var clientId = UUID.fromString(new JSONArray(getClientDetails(Map.of("clientId", keycloakDefaultRealmDefaultClientId))) + .getJSONObject(0).getString("id")); + userDetails.put("clientId", clientId); + + var clientSecret = getClientSecret(clientId); + if (clientSecret.contains(VALUE_KEY)) { + userDetails.put("clientSecret", new JSONObject(clientSecret).getString(VALUE_KEY)); + } + + var clientLevelMappingArray = new JSONArray(getClientLevelRoleMappings(userId, clientId)); + if (clientLevelMappingArray.length() > 0) { + var clientLevelMappingDetails = clientLevelMappingArray.getJSONObject(0); + updateUserRoles(userId, clientId, List.of(Map.of( + "id", clientLevelMappingDetails.getString("id"), + "name", clientLevelMappingDetails.getString("name"), + "description", clientLevelMappingDetails.getString("description"), + "composite", clientLevelMappingDetails.getBoolean("composite"), + "clientRole", clientLevelMappingDetails.getBoolean("clientRole"), + "containerId", clientLevelMappingDetails.getString("containerId")))); + } else { + LOGGER.warn("Roles are already mapped or no role mappings found."); + } + } catch (Exception e) { + throw (KeycloakSynchronizationException)new KeycloakSynchronizationException(e.getMessage()).initCause(e); + } + return userDetails; + } } diff --git a/src/i18n-support/src/main/java/engineering/everest/lhotse/i18n/exceptions/TranslatableException.java b/src/i18n-support/src/main/java/engineering/everest/lhotse/i18n/exceptions/TranslatableException.java index fcbae1dd..3bc0853c 100644 --- a/src/i18n-support/src/main/java/engineering/everest/lhotse/i18n/exceptions/TranslatableException.java +++ b/src/i18n-support/src/main/java/engineering/everest/lhotse/i18n/exceptions/TranslatableException.java @@ -8,7 +8,7 @@ public class TranslatableException extends RuntimeException { private final String i18nMessageKey; - private final Object[] args; + private final transient Object[] args; public TranslatableException(String i18nMessageKey) { super(i18nMessageKey); diff --git a/src/keycloak-support/src/main/java/engineering/everest/lhotse/api/config/CustomMethodSecurityExpression.java b/src/keycloak-support/src/main/java/engineering/everest/lhotse/api/config/CustomMethodSecurityExpression.java index 4bc61931..36207de0 100644 --- a/src/keycloak-support/src/main/java/engineering/everest/lhotse/api/config/CustomMethodSecurityExpression.java +++ b/src/keycloak-support/src/main/java/engineering/everest/lhotse/api/config/CustomMethodSecurityExpression.java @@ -49,7 +49,7 @@ public boolean belongsToOrg(UUID organizationId) { @Override public void setFilterObject(Object o) { - // Todo + // Do nothing } @Override @@ -59,7 +59,7 @@ public Object getFilterObject() { @Override public void setReturnObject(Object returnObject) { - // Todo + // Do nothing } @Override diff --git a/src/launcher/build.gradle b/src/launcher/build.gradle index 1051a768..b7f3e64e 100644 --- a/src/launcher/build.gradle +++ b/src/launcher/build.gradle @@ -1,11 +1,12 @@ buildscript { repositories { - jcenter() + mavenCentral() } } plugins { id 'org.springframework.boot' + id "de.undercouch.download" version "${undercouchVersion}" } apply plugin: 'jacoco' @@ -85,3 +86,36 @@ dependencyManagement { mavenBom "org.keycloak.bom:keycloak-adapter-bom:${keycloakVersion}" } } + +task downloadKeycloakZipFile(type: Download) { + src "https://github.com/keycloak/keycloak/releases/download/${keycloakTestServerVersion}/keycloak-${keycloakTestServerVersion}.zip" + dest new File(buildDir, "keycloak-${keycloakTestServerVersion}.zip") + onlyIfModified true +} + +task downloadAndUnzipKeycloakFile(dependsOn: downloadKeycloakZipFile, type: Copy) { + from zipTree(downloadKeycloakZipFile.dest) + into buildDir +} + +task startServer(dependsOn: 'downloadAndUnzipKeycloakFile') { + doLast { + def keycloakDir = "$buildDir/keycloak-${keycloakTestServerVersion}" + def port = 8180 + def waitTime = 10000 + def testUser = "admin@everest.engineering" + def testPass = "ac0n3x72" + + def createUser = "$keycloakDir/bin/add-user-keycloak.sh -r master -u $testUser -p $testPass".execute() + createUser.consumeProcessOutput(System.out, System.err) + createUser.waitForOrKill(waitTime) + + def startServer = "$keycloakDir/bin/standalone.sh -Djboss.http.port=$port".execute() + startServer.consumeProcessOutput(System.out, System.err) + startServer.waitForOrKill(3 * waitTime) + + def createRealm = "$keycloakDir/bin/kcadm.sh create realms -f $rootDir/imports/realm-export.json --no-config --server http://localhost:$port/auth --realm master --user $testUser --password $testPass".execute() + createRealm.consumeProcessOutput(System.out, System.err) + createRealm.waitForOrKill(waitTime) + } +} diff --git a/src/launcher/src/main/java/engineering/everest/lhotse/AdminProvisionTask.java b/src/launcher/src/main/java/engineering/everest/lhotse/AdminProvisionTask.java new file mode 100644 index 00000000..8235c39a --- /dev/null +++ b/src/launcher/src/main/java/engineering/everest/lhotse/AdminProvisionTask.java @@ -0,0 +1,71 @@ +package engineering.everest.lhotse; + +import engineering.everest.lhotse.axon.common.domain.Role; +import engineering.everest.lhotse.axon.common.services.KeycloakSynchronizationService; +import engineering.everest.lhotse.axon.replay.ReplayCompletionAware; +import engineering.everest.lhotse.users.persistence.PersistableUser; +import engineering.everest.lhotse.users.persistence.UsersRepository; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Instant; +import java.util.EnumSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import javax.annotation.PostConstruct; + +import static engineering.everest.lhotse.axon.common.domain.Role.ORG_USER; +import static engineering.everest.lhotse.axon.common.domain.Role.ORG_ADMIN; +import static engineering.everest.lhotse.axon.common.domain.User.ADMIN_ID; +import static java.util.UUID.fromString; + +@Component +@Log4j2 +public class AdminProvisionTask implements ReplayCompletionAware { + public static final UUID ORGANIZATION_ID = fromString("00000000-0000-0000-0000-000000000000"); + private static final String ADMIN_DISPLAY_NAME = "Admin"; + + private final Clock clock; + private final UsersRepository usersRepository; + private final KeycloakSynchronizationService keycloakSynchronizationService; + private final String adminUsername; + private final String adminPassword; + + public AdminProvisionTask(Clock clock, UsersRepository usersRepository, + KeycloakSynchronizationService keycloakSynchronizationService, + @Value("${kc.server.admin-user}") String adminUsername, + @Value("${kc.server.admin-password}") String adminPassword) { + this.clock = clock; + this.usersRepository = usersRepository; + this.keycloakSynchronizationService = keycloakSynchronizationService; + this.adminUsername = adminUsername; + this.adminPassword = adminPassword; + } + + @PostConstruct + public Map run() { + var userDetails = keycloakSynchronizationService.setupKeycloakUser(adminUsername, adminUsername, true, ORGANIZATION_ID, + Set.of(Role.ORG_USER, Role.ORG_ADMIN), ADMIN_DISPLAY_NAME, adminPassword, false); + + Optional adminUser = usersRepository.findByUsernameIgnoreCase(adminUsername); + if (adminUser.isPresent()) { + LOGGER.info("Skipping provisioning of admin account since it already exists"); + return userDetails; + } + + LOGGER.info("Provisioning admin user"); + usersRepository.save(new PersistableUser(UUID.fromString(userDetails.getOrDefault("userId", ADMIN_ID).toString()), + ORGANIZATION_ID, adminUsername, ADMIN_DISPLAY_NAME, false, EnumSet.of(ORG_USER, ORG_ADMIN), Instant.now(clock))); + + return userDetails; + } + + @Override + public void replayCompleted() { + run(); + } +} diff --git a/src/launcher/src/main/resources/application-standalone.properties b/src/launcher/src/main/resources/application-standalone.properties index 36199cd2..7c72b8ab 100644 --- a/src/launcher/src/main/resources/application-standalone.properties +++ b/src/launcher/src/main/resources/application-standalone.properties @@ -7,16 +7,16 @@ application.setup.admin.password=ac0n3x72 ############################################################################### # Database -axon.datasource.hikari.jdbcUrl=jdbc:h2:mem:axon +axon.datasource.hikari.jdbcUrl=jdbc:h2:mem:axon;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE axon.datasource.hikari.driver-class-name=org.h2.Driver axon.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect # See https://hibernate.atlassian.net/browse/HHH-12368 axon.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true -projections.datasource.hikari.jdbcUrl=jdbc:h2:mem:projections +projections.datasource.hikari.jdbcUrl=jdbc:h2:mem:projections;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE projections.datasource.hikari.driver-class-name=org.h2.Driver projections.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect -file-mappings.datasource.hikari.jdbcUrl=jdbc:h2:mem:filemappings +file-mappings.datasource.hikari.jdbcUrl=jdbc:h2:mem:filemappings;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE file-mappings.datasource.hikari.driverClassName=org.h2.Driver file-mappings.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect diff --git a/src/launcher/src/test/java/engineering/everest/lhotse/AdminProvisionTaskTest.java b/src/launcher/src/test/java/engineering/everest/lhotse/AdminProvisionTaskTest.java new file mode 100644 index 00000000..45a45859 --- /dev/null +++ b/src/launcher/src/test/java/engineering/everest/lhotse/AdminProvisionTaskTest.java @@ -0,0 +1,90 @@ +package engineering.everest.lhotse; + +import engineering.everest.lhotse.axon.common.domain.Role; +import engineering.everest.lhotse.axon.common.services.KeycloakSynchronizationService; +import engineering.everest.lhotse.users.persistence.PersistableUser; +import engineering.everest.lhotse.users.persistence.UsersRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.EnumSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static engineering.everest.lhotse.axon.common.domain.User.ADMIN_ID; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(SpringExtension.class) +class AdminProvisionTaskTest { + private static final String ADMIN_DISPLAY_NAME = "Admin"; + private static final String ADMIN_USERNAME = "admin-username"; + private static final String ADMIN_PASSWORD = "admin-raw-password"; + private static final EnumSet ROLES = EnumSet.of(Role.ORG_USER, Role.ORG_ADMIN); + + private AdminProvisionTask adminProvisionTask; + + private final Clock clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()); + @Mock + private UsersRepository usersRepository; + @Mock + private KeycloakSynchronizationService keycloakSynchronizationService; + + @BeforeEach + void setUp() { + adminProvisionTask = new AdminProvisionTask(clock, usersRepository, keycloakSynchronizationService, ADMIN_USERNAME, ADMIN_PASSWORD); + + when(keycloakSynchronizationService.setupKeycloakUser(ADMIN_USERNAME, ADMIN_USERNAME, true, AdminProvisionTask.ORGANIZATION_ID, + Set.of(Role.ORG_USER, Role.ORG_ADMIN), ADMIN_DISPLAY_NAME, ADMIN_PASSWORD, false)).thenReturn( + Map.of("userId", ADMIN_ID) + ); + when(usersRepository.findByUsernameIgnoreCase(ADMIN_USERNAME)).thenReturn(Optional.empty()); + } + + @Test + void run_WillCreateAdminUser_WhenAdminUserNotPresentInUsersProjection() { + adminProvisionTask.run(); + + verify(usersRepository).save( + new PersistableUser(ADMIN_ID, AdminProvisionTask.ORGANIZATION_ID, ADMIN_USERNAME, + ADMIN_DISPLAY_NAME, false, ROLES, Instant.now(clock))); + } + + @Test + void run_WillSkipCreatingAdminUser_WhenAdminUserAlreadyExists() { + when(usersRepository.findByUsernameIgnoreCase(ADMIN_USERNAME)).thenReturn(Optional.of( + new PersistableUser(ADMIN_ID, AdminProvisionTask.ORGANIZATION_ID, ADMIN_USERNAME, ADMIN_DISPLAY_NAME, + false, ROLES, Instant.now(clock)) + )); + adminProvisionTask.run(); + + verify(usersRepository, never()).save(any(PersistableUser.class)); + } + + @Test + void replayCompleted_WillCreateAdminUser_WhenAdminUserNotPresentInUsersProjection() { + adminProvisionTask.replayCompleted(); + + verify(usersRepository).save( + new PersistableUser(ADMIN_ID, AdminProvisionTask.ORGANIZATION_ID, ADMIN_USERNAME, ADMIN_DISPLAY_NAME, false, ROLES, Instant.now(clock))); + } + + @Test + void replayCompleted_WillSkipCreatingAdminUser_WhenAdminUserAlreadyExists() { + when(usersRepository.findByUsernameIgnoreCase(ADMIN_USERNAME)).thenReturn(Optional.of( + new PersistableUser(ADMIN_ID, AdminProvisionTask.ORGANIZATION_ID, ADMIN_USERNAME, ADMIN_DISPLAY_NAME, + false, ROLES, Instant.now(clock)))); + adminProvisionTask.replayCompleted(); + + verify(usersRepository, never()).save(any(PersistableUser.class)); + } +} diff --git a/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/helpers/ApiRestTestClient.java b/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/helpers/ApiRestTestClient.java index 96f8396b..bec68a02 100644 --- a/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/helpers/ApiRestTestClient.java +++ b/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/helpers/ApiRestTestClient.java @@ -1,5 +1,6 @@ package engineering.everest.lhotse.functionaltests.helpers; +import engineering.everest.lhotse.AdminProvisionTask; import engineering.everest.lhotse.api.rest.requests.NewOrganizationRequest; import engineering.everest.lhotse.api.rest.requests.NewUserRequest; import engineering.everest.lhotse.api.rest.requests.RegisterOrganizationRequest; @@ -7,10 +8,13 @@ import engineering.everest.lhotse.api.rest.responses.OrganizationRegistrationResponse; import engineering.everest.lhotse.api.rest.responses.OrganizationResponse; import engineering.everest.lhotse.api.rest.responses.UserResponse; +import engineering.everest.lhotse.axon.common.services.KeycloakSynchronizationService; +import lombok.extern.slf4j.Slf4j; import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder; import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.KeycloakBuilder; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpStatus; @@ -27,6 +31,7 @@ import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.web.reactive.function.BodyInserters.fromValue; +@Slf4j @Service public class ApiRestTestClient { @Value("${keycloak.auth-server-url}") @@ -39,19 +44,29 @@ public class ApiRestTestClient { private String keycloakAdminRealm; @Value("${keycloak.resource}") private String keycloakAdminClientId; - @Value("${keycloak.credentials.secret}") - private String keycloakAdminClientSecret; @Value("${kc.server.connection.pool-size}") private int keycloakServerConnectionPoolSize; + @Autowired + private KeycloakSynchronizationService keycloakSynchronizationService; + private final WebTestClient webTestClient; + private final AdminProvisionTask adminProvisionTask; private String accessToken; + private String clientSecret; - public ApiRestTestClient(WebTestClient webTestClient) { + public ApiRestTestClient(WebTestClient webTestClient, AdminProvisionTask adminProvisionTask) { this.webTestClient = webTestClient; + this.adminProvisionTask = adminProvisionTask; } public void createAdminUserAndLogin() { + var userDetails = adminProvisionTask.run(); + assertNotNull(userDetails); + + clientSecret = userDetails.getOrDefault("clientSecret", null).toString(); + assertNotNull(clientSecret); + login(keycloakAdminUser, keycloakAdminPassword); } @@ -65,7 +80,7 @@ public void login(String username, String password) { .grantType(OAuth2Constants.PASSWORD) .realm(keycloakAdminRealm) .clientId(keycloakAdminClientId) - .clientSecret(keycloakAdminClientSecret) + .clientSecret(clientSecret) .username(username) .password(password) .resteasyClient(new ResteasyClientBuilder() diff --git a/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/ApplicationFunctionalTests.java b/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/ApplicationFunctionalTests.java index 44a52c21..add1b45d 100644 --- a/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/ApplicationFunctionalTests.java +++ b/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/ApplicationFunctionalTests.java @@ -1,17 +1,20 @@ package engineering.everest.lhotse.functionaltests.scenarios; -import com.hazelcast.core.HazelcastInstance; import engineering.everest.lhotse.Launcher; import engineering.everest.lhotse.api.rest.requests.NewOrganizationRequest; import engineering.everest.lhotse.api.rest.requests.NewUserRequest; import engineering.everest.lhotse.api.rest.requests.RegisterOrganizationRequest; +import engineering.everest.lhotse.api.rest.requests.UpdateUserRequest; import engineering.everest.lhotse.axon.CommandValidatingMessageHandlerInterceptor; +import engineering.everest.lhotse.axon.common.RetryWithExponentialBackoff; +import engineering.everest.lhotse.axon.common.domain.Role; +import engineering.everest.lhotse.axon.common.services.KeycloakSynchronizationService; import engineering.everest.lhotse.functionaltests.helpers.ApiRestTestClient; -import engineering.everest.lhotse.users.persistence.UsersRepository; +import engineering.everest.lhotse.organizations.services.OrganizationsReadService; +import engineering.everest.lhotse.users.services.UsersReadService; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -20,30 +23,36 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.reactive.server.WebTestClient; +import java.time.Duration; import java.util.Map; +import java.util.Set; +import java.util.UUID; +import static java.util.concurrent.TimeUnit.MILLISECONDS; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.OK; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.web.reactive.function.BodyInserters.fromValue; -@SpringBootTest(webEnvironment = RANDOM_PORT, classes = Launcher.class) +@SpringBootTest(webEnvironment = DEFINED_PORT, classes = Launcher.class) @ActiveProfiles("standalone") class ApplicationFunctionalTests { @Autowired private ApplicationContext applicationContext; @Autowired - private UsersRepository usersRepository; - @Autowired private WebTestClient webTestClient; @Autowired - private HazelcastInstance hazelcastInstance; - @Autowired private ApiRestTestClient apiRestTestClient; + @Autowired + private OrganizationsReadService organizationsReadService; + @Autowired + private UsersReadService usersReadService; + @Autowired + private KeycloakSynchronizationService keycloakSynchronizationService; @Test void commandValidatingMessageHandlerInterceptorWillBeRegistered() { @@ -61,32 +70,51 @@ void metricsEndpointPublishesAxonMetrics() { } @Test - @Disabled("To be revisited. DB query is returning empty results.") - void organizationsAndUsersCanBeCreated() { + void organizationsAndUsersCanBeCreatedAndUserDetailsCanBeUpdated() throws Exception { apiRestTestClient.createAdminUserAndLogin(); var newOrganizationRequest = new NewOrganizationRequest("ACME", "123 King St", "Melbourne", "Vic", "Oz", "3000", null, null, null, "admin@example.com"); - var newUserRequest = new NewUserRequest("user@example.com", "Captain Fancypants"); + var newUserRequest = new NewUserRequest("user@example.com", "Captain Fancypants"); var organizationId = apiRestTestClient.createOrganization(newOrganizationRequest, CREATED); + + var waiter = new RetryWithExponentialBackoff(Duration.ofMillis(200), 2L, Duration.ofMinutes(1), + sleepDuration -> MILLISECONDS.sleep(sleepDuration.toMillis())); + waiter.waitOrThrow(() -> organizationsReadService.exists(organizationId), "organization registration projection update"); + var userId = apiRestTestClient.createUser(organizationId, newUserRequest, CREATED); + waiter.waitOrThrow(() -> usersReadService.exists(userId), "user registration projection update"); apiRestTestClient.getUser(userId, OK); + + var userUpdateRequest = new UpdateUserRequest("Captain Jack Sparrow", "jack@example.com"); + apiRestTestClient.updateUser(userId, userUpdateRequest, OK); + waiter.waitOrThrow(() -> usersReadService.getById(userId).getEmail() + .equals(userUpdateRequest.getEmail()) || usersReadService.getById(userId).getDisplayName().equals(userUpdateRequest.getDisplayName()), + "=> user email or displayName projection update"); + assertEquals(apiRestTestClient.getUser(userId, OK).getDisplayName(), userUpdateRequest.getDisplayName()); + assertEquals(apiRestTestClient.getUser(userId, OK).getEmail(), userUpdateRequest.getEmail()); } @Test - @Disabled("To be revisited. " + - "Organization can be registered but login throws {\"error\":\"invalid_grant\",\"error_description\":\"Account is not fully set up\"}. " + - "Because a user is expected to update his password at initial login.") - void newUsersCanRegisterTheirOrganizationAndCreateNewUsersInTheirOrganization() throws InterruptedException { + void newUsersCanRegisterTheirOrganizationAndCreateNewUsersInTheirOrganization() throws Exception { apiRestTestClient.logout(); var registerOrganizationRequest = new RegisterOrganizationRequest("Alice's Art Artefactory", "123 Any Street", "Melbourne", "Victoria", "Australia", "3000", "http://alicesartartefactory.com", "Alice", "+61 422 123 456", "alice@example.com"); var organizationRegistrationResponse = apiRestTestClient.registerNewOrganization(registerOrganizationRequest, CREATED); var newOrganizationId = organizationRegistrationResponse.getNewOrganizationId(); - Thread.sleep(2000); // Default is now tracking event processor + var waiter = new RetryWithExponentialBackoff(Duration.ofMillis(200), 2L, Duration.ofMinutes(1), + sleepDuration -> MILLISECONDS.sleep(sleepDuration.toMillis())); + waiter.waitOrThrow(() -> organizationsReadService.exists(newOrganizationId), "organization registration projection update"); + + keycloakSynchronizationService.setupKeycloakUser("kitty@example.com", "kitty@example.com", true, UUID.randomUUID(), Set.of(Role.ORG_USER, Role.ORG_ADMIN), + "Kitty", "meow@123", false); + apiRestTestClient.login("kitty@example.com", "meow@123"); - apiRestTestClient.login("alice@example.com", "alicerocks"); var newUserRequest = new NewUserRequest("bob@example.com", "My name is Bob"); - apiRestTestClient.createUser(newOrganizationId, newUserRequest, CREATED); + var userId = apiRestTestClient.createUser(newOrganizationId, newUserRequest, CREATED); + waiter.waitOrThrow(() -> usersReadService.exists(userId), "user registration projection update"); + + assertEquals(apiRestTestClient.getUser(userId, OK).getUsername(), newUserRequest.getUsername()); + assertEquals(apiRestTestClient.getUser(userId, OK).getDisplayName(), newUserRequest.getDisplayName()); } @Data @@ -121,14 +149,20 @@ void jsr303errorMessagesAreInternationalized() { } @Test - void domainValidationErrorMessagesAreInternationalized() { + void domainValidationErrorMessagesAreInternationalized() throws Exception { apiRestTestClient.createAdminUserAndLogin(); var newOrganizationRequest = new NewOrganizationRequest("ACME", "123 King St", "Melbourne", "Vic", "Oz", "3000", null, null, null, "admin@example.com"); var newUserRequest = new NewUserRequest("user123@example.com", "Captain Fancypants"); var organizationId = apiRestTestClient.createOrganization(newOrganizationRequest, CREATED); - apiRestTestClient.createUser(organizationId, newUserRequest, CREATED); + + var waiter = new RetryWithExponentialBackoff(Duration.ofMillis(500), 2L, Duration.ofMinutes(1), + sleepDuration -> MILLISECONDS.sleep(sleepDuration.toMillis())); + waiter.waitOrThrow(() -> organizationsReadService.exists(organizationId), "organization registration projection update"); + + var userId = apiRestTestClient.createUser(organizationId, newUserRequest, CREATED); + waiter.waitOrThrow(() -> usersReadService.exists(userId), "user registration projection update"); var response = webTestClient.post().uri("/api/organizations/{organizationId}/users", organizationId) .header("Authorization", "Bearer " + apiRestTestClient.getAccessToken()) diff --git a/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/FileStoreFunctionalTests.java b/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/FileStoreFunctionalTests.java index a8bde3bc..c817a2a8 100644 --- a/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/FileStoreFunctionalTests.java +++ b/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/FileStoreFunctionalTests.java @@ -27,9 +27,9 @@ import static engineering.everest.starterkit.filestorage.FileStoreType.EPHEMERAL; import static engineering.everest.starterkit.filestorage.NativeStorageType.MONGO_GRID_FS; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; -@SpringBootTest(webEnvironment = RANDOM_PORT, classes = Launcher.class) +@SpringBootTest(webEnvironment = DEFINED_PORT, classes = Launcher.class) @ActiveProfiles("standalone") @Transactional class FileStoreFunctionalTests { diff --git a/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/ReplayFunctionalTests.java b/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/ReplayFunctionalTests.java index a08a55bf..b4195f18 100644 --- a/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/ReplayFunctionalTests.java +++ b/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/ReplayFunctionalTests.java @@ -20,12 +20,12 @@ import static java.lang.Boolean.FALSE; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; -import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.NO_CONTENT; import static org.springframework.http.HttpStatus.OK; -@SpringBootTest(webEnvironment = RANDOM_PORT, classes = Launcher.class) +@SpringBootTest(webEnvironment = DEFINED_PORT, classes = Launcher.class) @ActiveProfiles("standalone") class ReplayFunctionalTests { diff --git a/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/SecurityFunctionalTests.java b/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/SecurityFunctionalTests.java index 243b3adb..88c63a04 100644 --- a/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/SecurityFunctionalTests.java +++ b/src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/SecurityFunctionalTests.java @@ -14,9 +14,9 @@ import java.nio.file.Paths; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; -@SpringBootTest(webEnvironment = RANDOM_PORT, classes = Launcher.class) +@SpringBootTest(webEnvironment = DEFINED_PORT, classes = Launcher.class) @ActiveProfiles("standalone") class SecurityFunctionalTests { diff --git a/src/organizations/src/main/java/engineering/everest/lhotse/organizations/config/OrganizationRepositoryConfig.java b/src/organizations/src/main/java/engineering/everest/lhotse/organizations/config/OrganizationRepositoryConfig.java index 7b74b69d..69a8dfc0 100644 --- a/src/organizations/src/main/java/engineering/everest/lhotse/organizations/config/OrganizationRepositoryConfig.java +++ b/src/organizations/src/main/java/engineering/everest/lhotse/organizations/config/OrganizationRepositoryConfig.java @@ -2,7 +2,6 @@ import engineering.everest.lhotse.organizations.domain.OrganizationAggregate; import org.axonframework.common.caching.JCacheAdapter; -import org.axonframework.eventsourcing.CachingEventSourcingRepository; import org.axonframework.eventsourcing.EventCountSnapshotTriggerDefinition; import org.axonframework.eventsourcing.GenericAggregateFactory; import org.axonframework.eventsourcing.Snapshotter; @@ -13,6 +12,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import static org.axonframework.eventsourcing.CachingEventSourcingRepository.builder; + @Configuration public class OrganizationRepositoryConfig { @@ -29,8 +30,7 @@ public OrganizationRepositoryConfig(ParameterResolverFactory parameterResolverFa public Repository repositoryForOrganization(EventStore eventStore, Snapshotter snapshotter, JCacheAdapter cacheAdapter) { - - return CachingEventSourcingRepository.builder(OrganizationAggregate.class) + return builder(OrganizationAggregate.class) .aggregateFactory(new GenericAggregateFactory<>(OrganizationAggregate.class)) .parameterResolverFactory(parameterResolverFactory) .snapshotTriggerDefinition(new EventCountSnapshotTriggerDefinition(snapshotter, SNAPSHOT_EVENT_COUNT_THRESHOLD)) diff --git a/src/organizations/src/main/java/engineering/everest/lhotse/organizations/domain/OrganizationAggregate.java b/src/organizations/src/main/java/engineering/everest/lhotse/organizations/domain/OrganizationAggregate.java index ed1f588c..52181830 100644 --- a/src/organizations/src/main/java/engineering/everest/lhotse/organizations/domain/OrganizationAggregate.java +++ b/src/organizations/src/main/java/engineering/everest/lhotse/organizations/domain/OrganizationAggregate.java @@ -37,7 +37,6 @@ public class OrganizationAggregate implements Serializable { @AggregateIdentifier private UUID id; - private String organizationName; @AggregateMember private final OrganizationContactDetails organizationContactDetails = new OrganizationContactDetails(); private boolean disabled; @@ -108,7 +107,6 @@ public void handle(UpdateOrganizationCommand command) { @EventSourcingHandler void on(OrganizationCreatedByAdminEvent event) { id = event.getOrganizationId(); - organizationName = event.getOrganizationName(); organizationAdminIds = new HashSet<>(); disabled = false; } @@ -116,7 +114,6 @@ void on(OrganizationCreatedByAdminEvent event) { @EventSourcingHandler void on(OrganizationCreatedForNewSelfRegisteredUserEvent event) { id = event.getOrganizationId(); - organizationName = event.getOrganizationName(); organizationAdminIds = new HashSet<>(); disabled = false; } diff --git a/src/pending-registrations/src/main/java/engineering/everest/lhotse/registrations/domain/OrganizationRegistrationSaga.java b/src/pending-registrations/src/main/java/engineering/everest/lhotse/registrations/domain/OrganizationRegistrationSaga.java index 7a58e834..fb9a99c6 100644 --- a/src/pending-registrations/src/main/java/engineering/everest/lhotse/registrations/domain/OrganizationRegistrationSaga.java +++ b/src/pending-registrations/src/main/java/engineering/everest/lhotse/registrations/domain/OrganizationRegistrationSaga.java @@ -31,7 +31,7 @@ @Saga @Revision("0") -@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) // TODO there might be a cleaner way +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) public class OrganizationRegistrationSaga { private static final String ORGANIZATION_PROPERTY = "organizationId"; @@ -68,7 +68,6 @@ public void on(OrganizationCreatedForNewSelfRegisteredUserEvent event) { var registeringUserId = event.getRegisteringUserId(); var registeringUserDisplayName = event.getContactName(); var registeringUserEmail = event.getContactEmail(); - //var registeringUserEncodedPassword = "encoded-password"; commandGateway.send(new CreateUserForNewlyRegisteredOrganizationCommand(organizationId, registeringUserId, registeringUserEmail, registeringUserDisplayName)); diff --git a/src/users-api/src/main/java/engineering/everest/lhotse/users/domain/commands/CreateUserForNewlyRegisteredOrganizationCommand.java b/src/users-api/src/main/java/engineering/everest/lhotse/users/domain/commands/CreateUserForNewlyRegisteredOrganizationCommand.java index bd95ffcb..cb78d215 100644 --- a/src/users-api/src/main/java/engineering/everest/lhotse/users/domain/commands/CreateUserForNewlyRegisteredOrganizationCommand.java +++ b/src/users-api/src/main/java/engineering/everest/lhotse/users/domain/commands/CreateUserForNewlyRegisteredOrganizationCommand.java @@ -5,6 +5,8 @@ import lombok.Data; import org.axonframework.modelling.command.TargetAggregateIdentifier; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; import java.util.UUID; @Data @@ -12,7 +14,10 @@ public class CreateUserForNewlyRegisteredOrganizationCommand implements ValidatableCommand { @TargetAggregateIdentifier UUID organizationId; + @NotNull UUID userId; + @NotNull String userEmail; + @NotBlank String displayName; } diff --git a/src/users/src/main/java/engineering/everest/lhotse/users/config/UsersRepositoryConfig.java b/src/users/src/main/java/engineering/everest/lhotse/users/config/UsersRepositoryConfig.java index d0788698..2eec4a5f 100644 --- a/src/users/src/main/java/engineering/everest/lhotse/users/config/UsersRepositoryConfig.java +++ b/src/users/src/main/java/engineering/everest/lhotse/users/config/UsersRepositoryConfig.java @@ -2,7 +2,6 @@ import engineering.everest.lhotse.users.domain.UserAggregate; import org.axonframework.common.caching.JCacheAdapter; -import org.axonframework.eventsourcing.CachingEventSourcingRepository; import org.axonframework.eventsourcing.EventCountSnapshotTriggerDefinition; import org.axonframework.eventsourcing.GenericAggregateFactory; import org.axonframework.eventsourcing.Snapshotter; @@ -13,6 +12,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import static org.axonframework.eventsourcing.CachingEventSourcingRepository.builder; + @Configuration public class UsersRepositoryConfig { @@ -27,8 +28,7 @@ public UsersRepositoryConfig(ParameterResolverFactory parameterResolverFactory) @Bean public Repository repositoryForUser(EventStore eventStore, Snapshotter snapshotter, JCacheAdapter cacheAdapter) { - - return CachingEventSourcingRepository.builder(UserAggregate.class) + return builder(UserAggregate.class) .aggregateFactory(new GenericAggregateFactory<>(UserAggregate.class)) .parameterResolverFactory(parameterResolverFactory) .snapshotTriggerDefinition(new EventCountSnapshotTriggerDefinition(snapshotter, SNAPSHOT_EVENT_COUNT_THRESHOLD)) diff --git a/src/users/src/main/java/engineering/everest/lhotse/users/domain/KeycloakSynchronizationSaga.java b/src/users/src/main/java/engineering/everest/lhotse/users/domain/KeycloakSynchronizationSaga.java new file mode 100644 index 00000000..6496031a --- /dev/null +++ b/src/users/src/main/java/engineering/everest/lhotse/users/domain/KeycloakSynchronizationSaga.java @@ -0,0 +1,71 @@ +package engineering.everest.lhotse.users.domain; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonIgnore; +import engineering.everest.lhotse.axon.common.RetryWithExponentialBackoff; +import engineering.everest.lhotse.axon.common.domain.UserAttribute; +import engineering.everest.lhotse.axon.common.services.KeycloakSynchronizationService; +import engineering.everest.lhotse.users.domain.events.UserDeletedAndForgottenEvent; +import engineering.everest.lhotse.users.domain.events.UserRolesUpdatedByAdminEvent; +import engineering.everest.lhotse.users.services.UsersReadService; +import org.axonframework.modelling.saga.EndSaga; +import org.axonframework.modelling.saga.SagaEventHandler; +import org.axonframework.modelling.saga.StartSaga; +import org.axonframework.serialization.Revision; +import org.axonframework.spring.stereotype.Saga; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.Callable; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +@Saga +@Revision("0") +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) +public class KeycloakSynchronizationSaga { + private static final String USER_ID_PROPERTY = "userId"; + private static final String DELETED_USER_ID_PROPERTY = "deletedUserId"; + + @JsonIgnore + private transient UsersReadService usersReadService; + + @Autowired + private KeycloakSynchronizationService keycloakSynchronizationService; + + @Autowired + public void setUsersReadService(UsersReadService usersReadService) { + this.usersReadService = usersReadService; + } + + @StartSaga + @EndSaga + @SagaEventHandler(associationProperty = USER_ID_PROPERTY) + public void on(UserRolesUpdatedByAdminEvent event) throws Exception { + var user = usersReadService.getById(event.getUserId()); + + waitForTheProjectionUpdate(() -> user.getRoles().equals(event.getRoles()), + "user roles projection update"); + + keycloakSynchronizationService.updateUserAttributes(event.getUserId(), + Map.of("attributes", new UserAttribute(user.getOrganizationId(), event.getRoles(), user.getDisplayName()))); + } + + @StartSaga + @EndSaga + @SagaEventHandler(associationProperty = DELETED_USER_ID_PROPERTY) + public void on(UserDeletedAndForgottenEvent event) throws Exception { + waitForTheProjectionUpdate(() -> !usersReadService.exists(event.getDeletedUserId()), + "user deletion projection update"); + + keycloakSynchronizationService.deleteUser(event.getDeletedUserId()); + } + + // Failure here will result in the saga not completing. + // Rollback has not been implemented in this example. + private void waitForTheProjectionUpdate(Callable condition, String message) throws Exception { + new RetryWithExponentialBackoff(Duration.ofMillis(200), 2L, Duration.ofMinutes(1), + x -> MILLISECONDS.sleep(x.toMillis())).waitOrThrow(condition, message); + } +} diff --git a/src/users/src/main/java/engineering/everest/lhotse/users/eventhandlers/UsersEventHandler.java b/src/users/src/main/java/engineering/everest/lhotse/users/eventhandlers/UsersEventHandler.java index 31414764..8d1180f6 100644 --- a/src/users/src/main/java/engineering/everest/lhotse/users/eventhandlers/UsersEventHandler.java +++ b/src/users/src/main/java/engineering/everest/lhotse/users/eventhandlers/UsersEventHandler.java @@ -2,8 +2,6 @@ import engineering.everest.axon.cryptoshredding.CryptoShreddingKeyService; import engineering.everest.axon.cryptoshredding.TypeDifferentiatedSecretKeyId; -import engineering.everest.lhotse.axon.common.domain.UserAttribute; -import engineering.everest.lhotse.axon.common.services.KeycloakSynchronizationService; import engineering.everest.lhotse.axon.replay.ReplayCompletionAware; import engineering.everest.lhotse.organizations.domain.events.UserPromotedToOrganizationAdminEvent; import engineering.everest.lhotse.users.domain.events.UserCreatedByAdminEvent; @@ -21,7 +19,6 @@ import org.springframework.stereotype.Service; import java.time.Instant; -import java.util.Map; import static engineering.everest.lhotse.axon.common.domain.Role.ORG_ADMIN; import static engineering.everest.lhotse.axon.common.domain.User.ADMIN_ID; @@ -32,14 +29,11 @@ public class UsersEventHandler implements ReplayCompletionAware { private final UsersRepository usersRepository; private final CryptoShreddingKeyService cryptoShreddingKeyService; - private final KeycloakSynchronizationService keycloakSynchronizationService; @Autowired - public UsersEventHandler(UsersRepository usersRepository, CryptoShreddingKeyService cryptoShreddingKeyService, - KeycloakSynchronizationService keycloakSynchronizationService) { + public UsersEventHandler(UsersRepository usersRepository, CryptoShreddingKeyService cryptoShreddingKeyService) { this.usersRepository = usersRepository; this.cryptoShreddingKeyService = cryptoShreddingKeyService; - this.keycloakSynchronizationService = keycloakSynchronizationService; } @ResetHandler @@ -82,12 +76,7 @@ void on(UserRolesUpdatedByAdminEvent event) { LOGGER.info("User {} roles updated by admin {}", event.getUserId(), event.getRoles()); var persistableUser = usersRepository.findById(event.getUserId()).orElseThrow(); persistableUser.setRoles(event.getRoles()); - var organizationId = persistableUser.getOrganizationId(); - var displayName = persistableUser.getDisplayName(); usersRepository.save(persistableUser); - - keycloakSynchronizationService.updateUserAttributes(event.getUserId(), - Map.of("attributes", new UserAttribute(organizationId, event.getRoles(), displayName))); } @EventHandler @@ -112,8 +101,6 @@ void on(UserDeletedAndForgottenEvent event) { usersRepository.deleteById(event.getDeletedUserId()); cryptoShreddingKeyService .deleteSecretKey(new TypeDifferentiatedSecretKeyId(event.getDeletedUserId().toString(), "")); - - keycloakSynchronizationService.deleteUser(event.getDeletedUserId()); } private String selectDesiredState(String desiredState, String currentState) { diff --git a/src/users/src/main/java/engineering/everest/lhotse/users/services/DefaultUsersService.java b/src/users/src/main/java/engineering/everest/lhotse/users/services/DefaultUsersService.java index 1d251467..533a7a50 100644 --- a/src/users/src/main/java/engineering/everest/lhotse/users/services/DefaultUsersService.java +++ b/src/users/src/main/java/engineering/everest/lhotse/users/services/DefaultUsersService.java @@ -1,7 +1,9 @@ package engineering.everest.lhotse.users.services; import engineering.everest.axon.HazelcastCommandGateway; +import engineering.everest.axon.exceptions.RemoteCommandExecutionException; import engineering.everest.lhotse.axon.common.domain.UserAttribute; +import engineering.everest.lhotse.axon.common.exceptions.KeycloakSynchronizationException; import engineering.everest.lhotse.axon.common.services.KeycloakSynchronizationService; import engineering.everest.lhotse.users.domain.commands.CreateUserCommand; import engineering.everest.lhotse.users.domain.commands.DeleteAndForgetUserCommand; @@ -12,25 +14,22 @@ import lombok.extern.log4j.Log4j2; import org.json.JSONArray; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import java.util.*; - -import static org.apache.commons.lang3.StringUtils.isBlank; +import java.util.List; +import java.util.UUID; +import java.util.Map; +import java.util.Set; @Service @Log4j2 public class DefaultUsersService implements UsersService { private final HazelcastCommandGateway commandGateway; - private final PasswordEncoder passwordEncoder; private final KeycloakSynchronizationService keycloakSynchronizationService; - public DefaultUsersService(HazelcastCommandGateway commandGateway, PasswordEncoder passwordEncoder, - KeycloakSynchronizationService keycloakSynchronizationService) { + public DefaultUsersService(HazelcastCommandGateway commandGateway, KeycloakSynchronizationService keycloakSynchronizationService) { this.commandGateway = commandGateway; - this.passwordEncoder = passwordEncoder; this.keycloakSynchronizationService = keycloakSynchronizationService; } @@ -46,7 +45,6 @@ public void updateUserRoles(UUID requestingUserId, UUID userId, Set roles) @Override public UUID createUser(UUID requestingUserId, UUID organizationId, String username, String displayName) { - var userId = UUID.randomUUID(); try { // Create user in the keycloak first so that we can get a newly created user id and map it in our app db. keycloakSynchronizationService.createUser( @@ -59,16 +57,15 @@ public UUID createUser(UUID requestingUserId, UUID organizationId, String userna "value", "changeme", "temporary", true)))); - userId = UUID.fromString( - new JSONArray(keycloakSynchronizationService.getUsers(Map.of("username", username))) - .getJSONObject(0).getString("id")); + return commandGateway.sendAndWait(new CreateUserCommand(getUserId(username), organizationId, + requestingUserId, username, displayName)); + } catch (RemoteCommandExecutionException tex) { + LOGGER.info("Keycloak createUser error: " + tex); + return commandGateway.sendAndWait(new CreateUserCommand(getUserId(username), organizationId, + requestingUserId, username, displayName)); } catch (Exception e) { - LOGGER.info("Keycloak createUser error: " + e); - throw new RuntimeException(e); + throw (KeycloakSynchronizationException)new KeycloakSynchronizationException(e.getMessage()).initCause(e); } - - return commandGateway.sendAndWait(new CreateUserCommand(userId, organizationId, - requestingUserId, username, displayName)); } @Override @@ -81,7 +78,9 @@ public void deleteAndForget(UUID requestingUserId, UUID userId, String requestRe commandGateway.sendAndWait(new DeleteAndForgetUserCommand(userId, requestingUserId, requestReason)); } - private String encodePasswordIfNotBlank(String passwordChange) { - return isBlank(passwordChange) ? passwordChange : passwordEncoder.encode(passwordChange); + private UUID getUserId(String username) { + return UUID.fromString( + new JSONArray(keycloakSynchronizationService.getUsers(Map.of("username", username))) + .getJSONObject(0).getString("id")); } } diff --git a/src/users/src/test/java/engineering/everest/lhotse/users/domain/KeycloakSynchronizationSagaTest.java b/src/users/src/test/java/engineering/everest/lhotse/users/domain/KeycloakSynchronizationSagaTest.java new file mode 100644 index 00000000..a1eb3772 --- /dev/null +++ b/src/users/src/test/java/engineering/everest/lhotse/users/domain/KeycloakSynchronizationSagaTest.java @@ -0,0 +1,97 @@ +package engineering.everest.lhotse.users.domain; + +import engineering.everest.lhotse.axon.common.domain.Role; +import engineering.everest.lhotse.axon.common.domain.User; +import engineering.everest.lhotse.axon.common.domain.UserAttribute; +import engineering.everest.lhotse.axon.common.services.KeycloakSynchronizationService; +import engineering.everest.lhotse.users.domain.events.UserDeletedAndForgottenEvent; +import engineering.everest.lhotse.users.domain.events.UserDetailsUpdatedByAdminEvent; +import engineering.everest.lhotse.users.domain.events.UserRolesUpdatedByAdminEvent; +import engineering.everest.lhotse.users.services.UsersReadService; +import org.axonframework.test.saga.SagaTestFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static java.util.UUID.randomUUID; + +@ExtendWith(MockitoExtension.class) +public class KeycloakSynchronizationSagaTest { + private SagaTestFixture testFixture; + + private static final UUID ORGANIZATION_ID = randomUUID(); + private static final UUID REGISTERING_USER_ID = randomUUID(); + private static final UUID REQUESTING_USER_ID = randomUUID(); + private static final String USERNAME = "tester"; + private static final String DISPLAY_NAME = "Tester"; + private static final Set roles = Set.of(Role.ORG_USER); + private static final String EMAIL_ID = "tester@everest.engineering"; + private static final UserRolesUpdatedByAdminEvent USER_ROLES_UPDATED_BY_ADMIN_EVENT = + new UserRolesUpdatedByAdminEvent(REGISTERING_USER_ID, roles, REQUESTING_USER_ID); + private static final UserDetailsUpdatedByAdminEvent USER_DETAILS_UPDATED_BY_ADMIN_EVENT = + new UserDetailsUpdatedByAdminEvent(REGISTERING_USER_ID, ORGANIZATION_ID, DISPLAY_NAME, EMAIL_ID,REQUESTING_USER_ID); + private static final UserDeletedAndForgottenEvent USER_DELETED_AND_FORGOTTEN_EVENT = + new UserDeletedAndForgottenEvent(REGISTERING_USER_ID, REQUESTING_USER_ID, "testing"); + private static final User USER = new User(REGISTERING_USER_ID, ORGANIZATION_ID, USERNAME, DISPLAY_NAME); + private static final User USER_2 = new User(REGISTERING_USER_ID, ORGANIZATION_ID, EMAIL_ID, DISPLAY_NAME, false); + + @Mock + private UsersReadService usersReadService; + @Mock + private KeycloakSynchronizationService keycloakSynchronizationService; + + @BeforeEach + void setUp() { + testFixture = new SagaTestFixture<>(KeycloakSynchronizationSaga.class); + testFixture.registerResource(usersReadService); + testFixture.registerResource(keycloakSynchronizationService); + testFixture.withTransienceCheckDisabled(); + } + + @Test + void userRolesUpdatedByAdminEvent_WillFireAnApiCallToUpdateRolesInKeycloak() { + USER.setRoles(roles); + when(usersReadService.getById(USER.getId())).thenReturn(USER); + + testFixture.givenAggregate(REGISTERING_USER_ID.toString()).published() + .whenAggregate(REGISTERING_USER_ID.toString()).publishes(USER_ROLES_UPDATED_BY_ADMIN_EVENT) + .expectNoDispatchedCommands() + .expectActiveSagas(0); + + verify(keycloakSynchronizationService).updateUserAttributes(USER.getId(), + Map.of("attributes", new UserAttribute(USER.getOrganizationId(), USER.getRoles(), USER.getDisplayName()))); + } + + @Test + void userDetailsUpdatedByAdminEvent_WillFireAnApiCallToUpdateDetailsInKeycloak() { + when(usersReadService.getById(USER_2.getId())).thenReturn(USER_2); + + testFixture.givenAggregate(REGISTERING_USER_ID.toString()).published() + .whenAggregate(REGISTERING_USER_ID.toString()).publishes(USER_DETAILS_UPDATED_BY_ADMIN_EVENT) + .expectNoDispatchedCommands() + .expectActiveSagas(0); + + verify(keycloakSynchronizationService).updateUserAttributes(USER_2.getId(), + Map.of("attributes", new UserAttribute(USER_2.getOrganizationId(), USER_2.getRoles(), USER_2.getDisplayName()), + "email", USER_2.getEmail())); + } + + @Test + void userDeletedAndForgottenEvent_WillFireAnApiCallToDeleteUserFromKeycloak() { + testFixture.givenAggregate(REGISTERING_USER_ID.toString()).published() + .whenAggregate(REGISTERING_USER_ID.toString()).publishes(USER_DELETED_AND_FORGOTTEN_EVENT) + .expectNoDispatchedCommands() + .expectActiveSagas(0); + + verify(keycloakSynchronizationService).deleteUser(USER.getId()); + } +} diff --git a/src/users/src/test/java/engineering/everest/lhotse/users/domain/UserAggregateTest.java b/src/users/src/test/java/engineering/everest/lhotse/users/domain/UserAggregateTest.java index 5c922c12..64e461e2 100644 --- a/src/users/src/test/java/engineering/everest/lhotse/users/domain/UserAggregateTest.java +++ b/src/users/src/test/java/engineering/everest/lhotse/users/domain/UserAggregateTest.java @@ -3,17 +3,20 @@ import engineering.everest.lhotse.axon.command.validators.EmailAddressValidator; import engineering.everest.lhotse.axon.command.validators.OrganizationStatusValidator; import engineering.everest.lhotse.axon.command.validators.UsersUniqueEmailValidator; +import engineering.everest.lhotse.axon.common.domain.Role; import engineering.everest.lhotse.i18n.exceptions.TranslatableIllegalArgumentException; import engineering.everest.lhotse.users.domain.commands.CreateUserCommand; import engineering.everest.lhotse.users.domain.commands.CreateUserForNewlyRegisteredOrganizationCommand; import engineering.everest.lhotse.users.domain.commands.DeleteAndForgetUserCommand; import engineering.everest.lhotse.users.domain.commands.RegisterUploadedUserProfilePhotoCommand; import engineering.everest.lhotse.users.domain.commands.UpdateUserDetailsCommand; +import engineering.everest.lhotse.users.domain.commands.UpdateUserRolesCommand; import engineering.everest.lhotse.users.domain.events.UserCreatedByAdminEvent; import engineering.everest.lhotse.users.domain.events.UserCreatedForNewlyRegisteredOrganizationEvent; import engineering.everest.lhotse.users.domain.events.UserDeletedAndForgottenEvent; import engineering.everest.lhotse.users.domain.events.UserDetailsUpdatedByAdminEvent; import engineering.everest.lhotse.users.domain.events.UserProfilePhotoUploadedEvent; +import engineering.everest.lhotse.users.domain.events.UserRolesUpdatedByAdminEvent; import engineering.everest.lhotse.users.services.UsersReadService; import org.axonframework.eventsourcing.AggregateDeletedException; import org.axonframework.spring.stereotype.Aggregate; @@ -26,6 +29,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import javax.validation.ConstraintViolationException; +import java.util.Set; import java.util.UUID; import static engineering.everest.lhotse.axon.AxonTestUtils.mockCommandValidatingMessageHandlerInterceptor; @@ -43,14 +47,15 @@ class UserAggregateTest { private static final UUID PROFILE_PHOTO_FILE_ID = randomUUID(); private static final String USERNAME = "user@email.com"; private static final String USER_DISPLAY_NAME = "user-display-name"; + private static final String USER_ENCODED_PASSWORD = "encoded-password"; private static final String DISPLAY_NAME_CHANGE = "display-name-change"; + private static final String ENCODED_PASSWORD_CHANGE = "encoded-password-change"; private static final String EMAIL_CHANGE = "email@change.com"; private static final String NO_CHANGE = null; private static final String BLANK_FIELD = ""; private static final UserCreatedByAdminEvent USER_CREATED_BY_ADMIN_EVENT = - new UserCreatedByAdminEvent(USER_ID, ORGANIZATION_ID, ADMIN_ID, USER_DISPLAY_NAME, USERNAME); - public static final UUID CONFIRMATION_CODE = randomUUID(); + new UserCreatedByAdminEvent(USER_ID, ORGANIZATION_ID, ADMIN_ID, USER_DISPLAY_NAME, USERNAME, USER_ENCODED_PASSWORD); private FixtureConfiguration testFixture; @@ -83,13 +88,20 @@ void aggregateHasExplicitlyDefinedRepository() { @Test void createUserCommandEmits_WhenAllMandatoryFieldsArePresentInCreationCommand() { testFixture.givenNoPriorActivity() - .when(new CreateUserCommand(USER_ID, ORGANIZATION_ID, ADMIN_ID, USERNAME, USER_DISPLAY_NAME)) - .expectEvents(new UserCreatedByAdminEvent(USER_ID, ORGANIZATION_ID, ADMIN_ID, USER_DISPLAY_NAME, USERNAME)); + .when(new CreateUserCommand(USER_ID, ORGANIZATION_ID, ADMIN_ID, USERNAME, USER_ENCODED_PASSWORD, USER_DISPLAY_NAME)) + .expectEvents(new UserCreatedByAdminEvent(USER_ID, ORGANIZATION_ID, ADMIN_ID, USER_DISPLAY_NAME, USERNAME, USER_ENCODED_PASSWORD)); + } + + @Test + void createUserForNewlyRegisteredOrganizationCommandEmits_WhenAllMandatoryFieldsArePresentInCreationCommand() { + testFixture.givenNoPriorActivity() + .when(new CreateUserForNewlyRegisteredOrganizationCommand(ORGANIZATION_ID, USER_ID, USERNAME, USER_ENCODED_PASSWORD, USER_DISPLAY_NAME)) + .expectEvents(new UserCreatedForNewlyRegisteredOrganizationEvent(ORGANIZATION_ID, USER_ID, USER_DISPLAY_NAME, USERNAME, USER_ENCODED_PASSWORD)); } @Test void rejectsCreateUserCommand_WhenEmailValidatorFails() { - CreateUserCommand command = new CreateUserCommand(USER_ID, ORGANIZATION_ID, ADMIN_ID, null, USER_DISPLAY_NAME); + CreateUserCommand command = new CreateUserCommand(USER_ID, ORGANIZATION_ID, ADMIN_ID, null, USER_ENCODED_PASSWORD, USER_DISPLAY_NAME); testFixture.givenNoPriorActivity() .when(command) @@ -97,10 +109,26 @@ void rejectsCreateUserCommand_WhenEmailValidatorFails() { .expectException(ConstraintViolationException.class); } + @Test + void rejectsCreateUserForNewlyRegisteredOrganizationCommand_WhenEmailValidatorFails() { + testFixture.givenNoPriorActivity() + .when(new CreateUserForNewlyRegisteredOrganizationCommand(ORGANIZATION_ID, USER_ID,null, USER_ENCODED_PASSWORD, USER_DISPLAY_NAME)) + .expectNoEvents() + .expectException(ConstraintViolationException.class); + } + @Test void rejectsCreateUserCommand_WhenDisplayNameIsBlank() { testFixture.givenNoPriorActivity() - .when(new CreateUserCommand(USER_ID, ORGANIZATION_ID, ADMIN_ID, USERNAME, "")) + .when(new CreateUserCommand(USER_ID, ORGANIZATION_ID, ADMIN_ID, USERNAME, USER_ENCODED_PASSWORD, "")) + .expectNoEvents() + .expectException(ConstraintViolationException.class); + } + + @Test + void rejectsCreateUserForNewlyRegisteredOrganizationCommand_WhenDisplayNameIsBlank() { + testFixture.givenNoPriorActivity() + .when(new CreateUserForNewlyRegisteredOrganizationCommand(ORGANIZATION_ID, USER_ID, USERNAME, USER_ENCODED_PASSWORD, "")) .expectNoEvents() .expectException(ConstraintViolationException.class); } @@ -108,14 +136,22 @@ void rejectsCreateUserCommand_WhenDisplayNameIsBlank() { @Test void rejectsCreateUserCommand_WhenDisplayNameIsNull() { testFixture.givenNoPriorActivity() - .when(new CreateUserCommand(USER_ID, ORGANIZATION_ID, ADMIN_ID, USERNAME, null)) + .when(new CreateUserCommand(USER_ID, ORGANIZATION_ID, ADMIN_ID, USERNAME, USER_ENCODED_PASSWORD, null)) + .expectNoEvents() + .expectException(ConstraintViolationException.class); + } + + @Test + void rejectsCreateUserForNewlyRegisteredOrganizationCommand_WhenDisplayNameIsNull() { + testFixture.givenNoPriorActivity() + .when(new CreateUserForNewlyRegisteredOrganizationCommand(ORGANIZATION_ID, USER_ID, USERNAME, USER_ENCODED_PASSWORD, null)) .expectNoEvents() .expectException(ConstraintViolationException.class); } @Test void rejectsCreateUserCommand_WhenRequestingUserIsNull() { - CreateUserCommand command = new CreateUserCommand(USER_ID, ORGANIZATION_ID, null, USERNAME, USER_DISPLAY_NAME); + CreateUserCommand command = new CreateUserCommand(USER_ID, ORGANIZATION_ID, null, USERNAME, USER_ENCODED_PASSWORD, USER_DISPLAY_NAME); testFixture.givenNoPriorActivity() .when(command) @@ -123,9 +159,17 @@ void rejectsCreateUserCommand_WhenRequestingUserIsNull() { .expectException(ConstraintViolationException.class); } + @Test + void rejectsCreateUserForNewlyRegisteredOrganizationCommand_WhenRequestingUserIsNull() { + testFixture.givenNoPriorActivity() + .when(new CreateUserForNewlyRegisteredOrganizationCommand(ORGANIZATION_ID, null, USERNAME, USER_ENCODED_PASSWORD, USER_DISPLAY_NAME)) + .expectNoEvents() + .expectException(ConstraintViolationException.class); + } + @Test void rejectsCreateUserCommand_WhenOrganizationIdIsInvalid() { - var command = new CreateUserCommand(USER_ID, ORGANIZATION_ID, ADMIN_ID, USERNAME, USER_DISPLAY_NAME); + var command = new CreateUserCommand(USER_ID, ORGANIZATION_ID, ADMIN_ID, USERNAME, USER_ENCODED_PASSWORD, USER_DISPLAY_NAME); doThrow(IllegalStateException.class).when(organizationStatusValidator).validate(command); testFixture.givenNoPriorActivity() @@ -136,7 +180,7 @@ void rejectsCreateUserCommand_WhenOrganizationIdIsInvalid() { @Test void rejectsCreateUserCommand_WhenUniqueUserEmailValidatorFails() { - var command = new CreateUserCommand(USER_ID, ORGANIZATION_ID, ADMIN_ID, USERNAME, USER_DISPLAY_NAME); + var command = new CreateUserCommand(USER_ID, ORGANIZATION_ID, ADMIN_ID, USERNAME, USER_DISPLAY_NAME, USER_ENCODED_PASSWORD); doThrow(IllegalArgumentException.class).when(usersUniqueEmailValidator).validate(command); testFixture.givenNoPriorActivity() @@ -146,24 +190,56 @@ void rejectsCreateUserCommand_WhenUniqueUserEmailValidatorFails() { } @Test - void createAdminUserForNewlyRegisteredOrganizationEmits() { + void createUserForNewlyRegisteredOrganizationCommandEmits() { + testFixture.givenNoPriorActivity() + .when(new CreateUserForNewlyRegisteredOrganizationCommand(USER_ID, ORGANIZATION_ID, USERNAME, USER_ENCODED_PASSWORD, USER_DISPLAY_NAME)) + .expectEvents(new UserCreatedForNewlyRegisteredOrganizationEvent(USER_ID, ORGANIZATION_ID, USER_DISPLAY_NAME, USERNAME, USER_ENCODED_PASSWORD)); + } + + @Test + void createUserCommandEmits() { testFixture.givenNoPriorActivity() - .when(new CreateUserForNewlyRegisteredOrganizationCommand(USER_ID, ORGANIZATION_ID, USERNAME, USER_DISPLAY_NAME)) - .expectEvents(new UserCreatedForNewlyRegisteredOrganizationEvent(USER_ID, ORGANIZATION_ID, USER_DISPLAY_NAME, USERNAME)); + .when(new CreateUserCommand(USER_ID, ORGANIZATION_ID, ADMIN_ID, USERNAME, USER_ENCODED_PASSWORD, USER_DISPLAY_NAME)) + .expectEvents(new UserCreatedByAdminEvent(USER_ID, ORGANIZATION_ID, ADMIN_ID, USER_DISPLAY_NAME, USERNAME, USER_ENCODED_PASSWORD)); } @Test void updateUserDetailsCommandEmits_WhenCommandAccepted() { testFixture.given(USER_CREATED_BY_ADMIN_EVENT) .when(new UpdateUserDetailsCommand(USER_ID, EMAIL_CHANGE, - DISPLAY_NAME_CHANGE, ADMIN_ID)) + DISPLAY_NAME_CHANGE, ENCODED_PASSWORD_CHANGE, ADMIN_ID)) .expectEvents(new UserDetailsUpdatedByAdminEvent(USER_ID, ORGANIZATION_ID, DISPLAY_NAME_CHANGE, - EMAIL_CHANGE, ADMIN_ID)); + EMAIL_CHANGE, ENCODED_PASSWORD_CHANGE, ADMIN_ID)); + } + + @Test + void updateUserRolesCommandEmitsUserRoles_WhenCommandAccepted() { + testFixture.given(USER_CREATED_BY_ADMIN_EVENT) + .when(new UpdateUserRolesCommand(USER_ID, Set.of(Role.ORG_USER, Role.ORG_ADMIN), ADMIN_ID)) + .expectEvents(new UserRolesUpdatedByAdminEvent(USER_ID, Set.of(Role.ORG_USER, Role.ORG_ADMIN), ADMIN_ID)); + } + + @Test + void rejectsUpdateUserRolesCommand_WhenRolesAreEmpty() { + testFixture.given(USER_CREATED_BY_ADMIN_EVENT) + .when(new UpdateUserRolesCommand(USER_ID, Set.of(), ADMIN_ID)) + .expectNoEvents() + .expectException(RuntimeException.class) + .expectExceptionMessage("Invalid message key for translatable exception USER_UPDATE_NO_ROLES_SPECIFIED"); + } + + @Test + void rejectsUpdateUserRolesCommand_WhenAdminRoleProvided() { + testFixture.given(USER_CREATED_BY_ADMIN_EVENT) + .when(new UpdateUserRolesCommand(USER_ID, Set.of(Role.ORG_ADMIN, Role.ADMIN), ADMIN_ID)) + .expectNoEvents() + .expectException(RuntimeException.class) + .expectExceptionMessage("Invalid message key for translatable exception USER_UPDATE_UNALLOWED_ROLE_ADMIN"); } @Test void rejectsUpdateUserDetailsCommand_WhenEmailValidatorFails() { - var command = new UpdateUserDetailsCommand(USER_ID, "not-a-valid-email", DISPLAY_NAME_CHANGE, ADMIN_ID); + var command = new UpdateUserDetailsCommand(USER_ID, "not-a-valid-email", DISPLAY_NAME_CHANGE, ENCODED_PASSWORD_CHANGE, ADMIN_ID); doThrow(IllegalArgumentException.class).when(emailAddressValidator).validate(command); testFixture.given(USER_CREATED_BY_ADMIN_EVENT) @@ -174,7 +250,7 @@ void rejectsUpdateUserDetailsCommand_WhenEmailValidatorFails() { @Test void rejectsUpdateUserDetailsCommand_WhenRequestingUserIdIsNull() { - var command = new UpdateUserDetailsCommand(USER_ID, EMAIL_CHANGE, DISPLAY_NAME_CHANGE, null); + var command = new UpdateUserDetailsCommand(USER_ID, EMAIL_CHANGE, DISPLAY_NAME_CHANGE, ENCODED_PASSWORD_CHANGE, null); testFixture.given(USER_CREATED_BY_ADMIN_EVENT) .when(command) @@ -185,7 +261,7 @@ void rejectsUpdateUserDetailsCommand_WhenRequestingUserIdIsNull() { @Test void rejectsUpdateUserDetailsCommand_WhenNoFieldsAreBeingChanged() { testFixture.given(USER_CREATED_BY_ADMIN_EVENT) - .when(new UpdateUserDetailsCommand(USER_ID, NO_CHANGE, NO_CHANGE, ADMIN_ID)) + .when(new UpdateUserDetailsCommand(USER_ID, NO_CHANGE, NO_CHANGE, NO_CHANGE, ADMIN_ID)) .expectNoEvents() .expectException(TranslatableIllegalArgumentException.class) .expectExceptionMessage("USER_UPDATE_NO_FIELDS_CHANGED"); @@ -194,7 +270,7 @@ void rejectsUpdateUserDetailsCommand_WhenNoFieldsAreBeingChanged() { @Test void rejectsUpdateUserDetailsCommand_WhenDisplayNameIsBlanked() { testFixture.given(USER_CREATED_BY_ADMIN_EVENT) - .when(new UpdateUserDetailsCommand(USER_ID, NO_CHANGE, BLANK_FIELD, ADMIN_ID)) + .when(new UpdateUserDetailsCommand(USER_ID, NO_CHANGE, BLANK_FIELD, NO_CHANGE, ADMIN_ID)) .expectNoEvents() .expectException(TranslatableIllegalArgumentException.class) .expectExceptionMessage("USER_DISPLAY_NAME_MISSING"); @@ -202,7 +278,7 @@ void rejectsUpdateUserDetailsCommand_WhenDisplayNameIsBlanked() { @Test void rejectsUpdateUserDetailsCommand_WhenUniqueEmailValidatorFails() { - UpdateUserDetailsCommand command = new UpdateUserDetailsCommand(USER_ID, EMAIL_CHANGE, DISPLAY_NAME_CHANGE, ADMIN_ID); + UpdateUserDetailsCommand command = new UpdateUserDetailsCommand(USER_ID, EMAIL_CHANGE, DISPLAY_NAME_CHANGE, NO_CHANGE, ADMIN_ID); doThrow(IllegalStateException.class).when(usersUniqueEmailValidator).validate(command); diff --git a/src/users/src/test/java/engineering/everest/lhotse/users/eventhandlers/UsersEventHandlerTest.java b/src/users/src/test/java/engineering/everest/lhotse/users/eventhandlers/UsersEventHandlerTest.java index 75535f4b..974046a6 100644 --- a/src/users/src/test/java/engineering/everest/lhotse/users/eventhandlers/UsersEventHandlerTest.java +++ b/src/users/src/test/java/engineering/everest/lhotse/users/eventhandlers/UsersEventHandlerTest.java @@ -2,7 +2,6 @@ import engineering.everest.axon.cryptoshredding.CryptoShreddingKeyService; import engineering.everest.axon.cryptoshredding.TypeDifferentiatedSecretKeyId; -import engineering.everest.lhotse.axon.common.services.KeycloakSynchronizationService; import engineering.everest.lhotse.organizations.domain.events.UserPromotedToOrganizationAdminEvent; import engineering.everest.lhotse.users.domain.events.UserCreatedByAdminEvent; import engineering.everest.lhotse.users.domain.events.UserCreatedForNewlyRegisteredOrganizationEvent; @@ -47,12 +46,10 @@ class UsersEventHandlerTest { private UsersRepository usersRepository; @Mock private CryptoShreddingKeyService cryptoShreddingKeyService; - @Mock - private KeycloakSynchronizationService keycloakSynchronizationService; @BeforeEach void setUp() { - usersEventHandler = new UsersEventHandler(usersRepository, cryptoShreddingKeyService, keycloakSynchronizationService); + usersEventHandler = new UsersEventHandler(usersRepository, cryptoShreddingKeyService); } @Test diff --git a/src/users/src/test/java/engineering/everest/lhotse/users/services/DefaultUsersServiceTest.java b/src/users/src/test/java/engineering/everest/lhotse/users/services/DefaultUsersServiceTest.java index 490aec3e..ec50d32e 100644 --- a/src/users/src/test/java/engineering/everest/lhotse/users/services/DefaultUsersServiceTest.java +++ b/src/users/src/test/java/engineering/everest/lhotse/users/services/DefaultUsersServiceTest.java @@ -1,19 +1,19 @@ package engineering.everest.lhotse.users.services; import engineering.everest.axon.HazelcastCommandGateway; +import engineering.everest.lhotse.axon.common.domain.Role; import engineering.everest.lhotse.axon.common.services.KeycloakSynchronizationService; import engineering.everest.lhotse.users.domain.commands.CreateUserCommand; import engineering.everest.lhotse.users.domain.commands.DeleteAndForgetUserCommand; import engineering.everest.lhotse.users.domain.commands.RegisterUploadedUserProfilePhotoCommand; import engineering.everest.lhotse.users.domain.commands.UpdateUserDetailsCommand; +import engineering.everest.lhotse.users.domain.commands.UpdateUserRolesCommand; import org.json.JSONArray; -import org.json.JSONObject; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.crypto.password.PasswordEncoder; import java.util.*; @@ -34,15 +34,13 @@ class DefaultUsersServiceTest { @Mock private HazelcastCommandGateway commandGateway; @Mock - private PasswordEncoder passwordEncoder; - @Mock private KeycloakSynchronizationService keycloakSynchronizationService; private DefaultUsersService defaultUsersService; @BeforeEach void setUp() { - defaultUsersService = new DefaultUsersService(commandGateway, passwordEncoder, keycloakSynchronizationService); + defaultUsersService = new DefaultUsersService(commandGateway, keycloakSynchronizationService); } @Test @@ -54,10 +52,20 @@ void updateUserDetails_WillSendCommandAndWaitForCompletion() { "display-name-change", ADMIN_ID)); } - /*@Test + @Test + void updateUserRoles_WillSendCommandAndWaitForCompletion() { + var roles = Set.of(Role.ORG_ADMIN, Role.ORG_USER); + defaultUsersService.updateUserRoles(ADMIN_ID, USER_ID, roles); + verify(commandGateway).sendAndWait(new UpdateUserRolesCommand(USER_ID, roles, ADMIN_ID)); + } + + @Test void createNewUser_WillSendCommandAndWaitForCompletion() { - -- TODO re-write test case -- - }*/ + when(keycloakSynchronizationService.getUsers(Map.of("username", NEW_USER_EMAIL))) + .thenReturn(new JSONArray().put(0, Map.of("id", USER_ID)).toString()); + defaultUsersService.createUser(ADMIN_ID, ORGANIZATION_ID, NEW_USER_EMAIL, NEW_USER_DISPLAY_NAME); + verify(commandGateway).sendAndWait(new CreateUserCommand(USER_ID, ORGANIZATION_ID, ADMIN_ID, NEW_USER_EMAIL, NEW_USER_DISPLAY_NAME)); + } @Test void storeProfilePhoto_WillSendCommandAndWaitForCompletion() {