diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java index ea8a7294d..591e46473 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java @@ -161,7 +161,10 @@ public class CommandSourceService { || "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) || "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) || "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) - || "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())) { + || "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) + || "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) + || "USER".equalsIgnoreCase(commandSource.getEntityName()) + || "ROLE".equalsIgnoreCase(commandSource.getEntityName())) { return false; } return true; diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java index 18a910266..2529cee37 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java @@ -26,6 +26,8 @@ public interface ConfigurationDomainService { boolean isMakerCheckerEnabledForTask(String taskPermissionCode); + boolean isMakerCheckerEnabled(); + /** * Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least * one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java index 9056094c5..c393095d9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java @@ -61,6 +61,11 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService return false; } + @Override + public boolean isMakerCheckerEnabled() { + return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.MAKER_CHECKER).isEnabled(); + } + @Override public List getAllowedLoanStatusesForExternalAssetTransfer() { final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData( diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java index 794a95eca..57744602b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java @@ -88,6 +88,11 @@ public class UsersApiResource { private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService; private final BulkImportWorkbookService bulkImportWorkbookService; + private final org.apache.fineract.useradministration.service.UserPendingWritePlatformService userPendingWritePlatformService; + private final org.apache.fineract.useradministration.service.UserPendingReadPlatformService userPendingReadPlatformService; + private final org.apache.fineract.infrastructure.core.serialization.FromJsonHelper fromApiJsonHelper; + private final org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService configurationDomainService; + private final DefaultToApiJsonSerializer pendingRequestApiJsonSerializer; @GET @Operation(summary = "Retrieve list of users", description = "Example Requests:\n" + "\n" + "users\n" + "\n" + "\n" @@ -158,6 +163,12 @@ public class UsersApiResource { @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) public String create(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + if (this.configurationDomainService.isMakerCheckerEnabled()) { + final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand + .fromJsonElement(null, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper); + final CommandProcessingResult result = this.userPendingWritePlatformService.submitCreateUser(command); + return this.toApiJsonSerializer.serialize(result); + } final CommandWrapper commandRequest = new CommandWrapperBuilder() // .createUser() // @@ -179,6 +190,12 @@ public class UsersApiResource { @Produces({ MediaType.APPLICATION_JSON }) public String update(@PathParam("userId") @Parameter(description = "userId") final Long userId, @Parameter(hidden = true) final String apiRequestBodyAsJson) { + if (this.configurationDomainService.isMakerCheckerEnabled()) { + final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand + .fromJsonElement(userId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper); + final CommandProcessingResult result = this.userPendingWritePlatformService.submitUpdateUser(userId, command); + return this.toApiJsonSerializer.serialize(result); + } final CommandWrapper commandRequest = new CommandWrapperBuilder() // .updateUser(userId) // @@ -190,6 +207,56 @@ public class UsersApiResource { return this.toApiJsonSerializer.serialize(result); } + @GET + @Path("pending") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "List User creations & edits pending approval") + public org.apache.fineract.infrastructure.core.service.Page retrieveAllPendingUsers( + @QueryParam("status") final String status, + @QueryParam("username") final String username, + @QueryParam("fromDate") final String fromDate, + @QueryParam("toDate") final String toDate, + @QueryParam("offset") final Integer offset, + @QueryParam("limit") final Integer limit) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.userPendingReadPlatformService.retrieveAllPendingRequests(status, username, fromDate, toDate, offset, limit); + } + + @GET + @Path("pending/{id}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve a pending user request by ID") + public String retrievePendingUser(@PathParam("id") final Long id, @Context final UriInfo uriInfo) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + final org.apache.fineract.useradministration.data.UserPendingRequestData data = this.userPendingReadPlatformService.retrievePendingRequest(id); + final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return this.pendingRequestApiJsonSerializer.serialize(settings, data); + } + + @POST + @Path("pending/{id}/approve") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Approve a pending User creation or update") + public String approvePendingUser(@PathParam("id") final Long id) { + final CommandProcessingResult result = this.userPendingWritePlatformService.approvePendingRequest(id); + return this.toApiJsonSerializer.serialize(result); + } + + @POST + @Path("pending/{id}/reject") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Reject a pending User creation or update") + public String rejectPendingUser(@PathParam("id") final Long id, @Parameter(hidden = true) final String apiRequestBodyAsJson) { + final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand + .fromJsonElement(id, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper); + final CommandProcessingResult result = this.userPendingWritePlatformService.rejectPendingRequest(id, command); + return this.toApiJsonSerializer.serialize(result); + } + @POST @Path("{userId}/pwd") @Operation(summary = "Change the password of a User", description = "When updating a password you must provide the repeatPassword parameter also.") diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/data/UserPendingRequestData.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/data/UserPendingRequestData.java new file mode 100644 index 000000000..dcdf066ec --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/data/UserPendingRequestData.java @@ -0,0 +1,59 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.data; + +import java.io.Serializable; +import java.time.OffsetDateTime; +import java.util.Collection; +import java.util.Map; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UserPendingRequestData implements Serializable { + + private static final long serialVersionUID = 1L; + + private final Long id; + private final Long userId; + private final String requestType; + private final String status; + private final String username; + private final String firstname; + private final String lastname; + private final String email; + private final String mobileNo; + private final Long officeId; + private final String officeName; + private final Long staffId; + private final String staffName; + private final String otpDeliveryMethod; + private final Boolean passwordNeverExpires; + private final Collection roleNames; + private final Long makerId; + private final String makerUsername; + private final OffsetDateTime submittedDate; + private final Long checkerId; + private final String checkerUsername; + private final OffsetDateTime checkedDate; + private final String rejectReason; + private final Map originalValues; + private final Map proposedValues; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequest.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequest.java new file mode 100644 index 000000000..a143d8598 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequest.java @@ -0,0 +1,113 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; + +@Entity +@Table(name = "m_user_pending_request") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Setter +public class UserPendingRequest extends AbstractPersistableCustom { + + @Column(name = "user_id") + private Long userId; + + @Column(name = "request_type", nullable = false, length = 20) + private String requestType; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "payload_json", nullable = false) + private String payloadJson; + + @Column(name = "original_json") + private String originalJson; + + @Column(name = "password_hash") + private String passwordHash; + + @Column(name = "maker_id", nullable = false) + private Long makerId; + + @Column(name = "submitted_date", nullable = false) + private OffsetDateTime submittedDate; + + @Column(name = "checker_id") + private Long checkerId; + + @Column(name = "checked_date") + private OffsetDateTime checkedDate; + + @Column(name = "reject_reason", length = 500) + private String rejectReason; + + @Column(name = "pending_lock_key", length = 100, unique = true) + private String pendingLockKey; + + public static UserPendingRequest create(final UserPendingRequestType requestType, final Long userId, + final String payloadJson, final String originalJson, final String passwordHash, + final Long makerId, final String pendingLockKey) { + final UserPendingRequest request = new UserPendingRequest(); + request.requestType = requestType.name(); + request.userId = userId; + request.status = UserPendingRequestStatus.PENDING.name(); + request.payloadJson = payloadJson; + request.originalJson = originalJson; + request.passwordHash = passwordHash; + request.makerId = makerId; + request.submittedDate = OffsetDateTime.now(ZoneOffset.UTC); + request.pendingLockKey = pendingLockKey; + return request; + } + + public boolean isPending() { + return UserPendingRequestStatus.PENDING.name().equals(this.status); + } + + public boolean isMadeBy(final Long appUserId) { + return this.makerId != null && this.makerId.equals(appUserId); + } + + public void approve(final Long checkerId) { + this.status = UserPendingRequestStatus.APPROVED.name(); + this.checkerId = checkerId; + this.checkedDate = OffsetDateTime.now(ZoneOffset.UTC); + this.pendingLockKey = null; + } + + public void reject(final Long checkerId, final String reason) { + this.status = UserPendingRequestStatus.REJECTED.name(); + this.checkerId = checkerId; + this.checkedDate = OffsetDateTime.now(ZoneOffset.UTC); + this.rejectReason = reason; + this.pendingLockKey = null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestRepository.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestRepository.java new file mode 100644 index 000000000..b622c6c3c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestRepository.java @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface UserPendingRequestRepository extends JpaRepository, JpaSpecificationExecutor { + + Optional findByPendingLockKey(String pendingLockKey); + + boolean existsByPendingLockKey(String pendingLockKey); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestRepositoryWrapper.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestRepositoryWrapper.java new file mode 100644 index 000000000..8259570c3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestRepositoryWrapper.java @@ -0,0 +1,49 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.domain; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.useradministration.exception.UserPendingRequestNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserPendingRequestRepositoryWrapper { + + private final UserPendingRequestRepository repository; + + @Transactional(readOnly = true) + public UserPendingRequest findOneWithNotFoundDetection(final Long id) { + return this.repository.findById(id).orElseThrow(() -> new UserPendingRequestNotFoundException(id)); + } + + public UserPendingRequest saveAndFlush(final UserPendingRequest request) { + return this.repository.saveAndFlush(request); + } + + public UserPendingRequest save(final UserPendingRequest request) { + return this.repository.save(request); + } + + @Transactional(readOnly = true) + public boolean existsByPendingLockKey(final String pendingLockKey) { + return this.repository.existsByPendingLockKey(pendingLockKey); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestStatus.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestStatus.java new file mode 100644 index 000000000..2633e4b98 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestStatus.java @@ -0,0 +1,37 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.domain; + +public enum UserPendingRequestStatus { + PENDING, + APPROVED, + REJECTED; + + public static UserPendingRequestStatus fromString(final String status) { + if (status == null) { + return null; + } + for (final UserPendingRequestStatus requestStatus : values()) { + if (requestStatus.name().equalsIgnoreCase(status)) { + return requestStatus; + } + } + throw new IllegalArgumentException("Unknown UserPendingRequestStatus: " + status); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestType.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestType.java new file mode 100644 index 000000000..2a9700978 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestType.java @@ -0,0 +1,36 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.domain; + +public enum UserPendingRequestType { + CREATE_USER, + UPDATE_USER; + + public static UserPendingRequestType fromString(final String type) { + if (type == null) { + return null; + } + for (final UserPendingRequestType requestType : values()) { + if (requestType.name().equalsIgnoreCase(type)) { + return requestType; + } + } + throw new IllegalArgumentException("Unknown UserPendingRequestType: " + type); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/exception/UserPendingRequestNotFoundException.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/exception/UserPendingRequestNotFoundException.java new file mode 100644 index 000000000..c2f5b9823 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/exception/UserPendingRequestNotFoundException.java @@ -0,0 +1,28 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException; + +public class UserPendingRequestNotFoundException extends AbstractPlatformResourceNotFoundException { + + public UserPendingRequestNotFoundException(final Long id) { + super("error.msg.userpendingrequest.id.invalid", "User pending request with identifier " + id + " does not exist", id); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformService.java new file mode 100644 index 000000000..4dac903d4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformService.java @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.service; + +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.useradministration.data.UserPendingRequestData; + +public interface UserPendingReadPlatformService { + + Page retrieveAllPendingRequests(String status, String username, String fromDate, String toDate, + Integer offset, Integer limit); + + UserPendingRequestData retrievePendingRequest(Long id); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformServiceImpl.java new file mode 100644 index 000000000..08d2f1038 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformServiceImpl.java @@ -0,0 +1,329 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.service; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.domain.JdbcSupport; +import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.useradministration.data.UserPendingRequestData; +import org.apache.fineract.useradministration.domain.UserPendingRequest; +import org.apache.fineract.useradministration.domain.UserPendingRequestRepositoryWrapper; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserPendingReadPlatformServiceImpl implements UserPendingReadPlatformService { + + private final JdbcTemplate jdbcTemplate; + private final UserPendingRequestRepositoryWrapper pendingRequestRepositoryWrapper; + private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson(); + + private static final String SELECT_SQL = """ + SELECT r.id AS id, + r.user_id AS userId, + r.request_type AS requestType, + r.status AS status, + r.payload_json AS payloadJson, + r.original_json AS originalJson, + r.maker_id AS makerId, + maker.username AS makerUsername, + r.submitted_date AS submittedDate, + r.checker_id AS checkerId, + checker.username AS checkerUsername, + r.checked_date AS checkedDate, + r.reject_reason AS rejectReason + FROM m_user_pending_request r + JOIN m_appuser maker ON maker.id = r.maker_id + LEFT JOIN m_appuser checker ON checker.id = r.checker_id + """; + + @Override + @Transactional(readOnly = true) + public Page retrieveAllPendingRequests(final String status, final String username, + final String fromDate, final String toDate, final Integer offset, final Integer limit) { + final StringBuilder where = new StringBuilder(" WHERE 1=1"); + final List args = new ArrayList<>(); + + if (StringUtils.isNotBlank(status)) { + where.append(" AND r.status = ?"); + args.add(status.trim().toUpperCase()); + } else { + where.append(" AND r.status = 'PENDING'"); + } + + if (StringUtils.isNotBlank(fromDate)) { + where.append(" AND r.submitted_date::date >= ?::date"); + args.add(fromDate); + } + if (StringUtils.isNotBlank(toDate)) { + where.append(" AND r.submitted_date::date <= ?::date"); + args.add(toDate); + } + + final String countSql = "SELECT COUNT(*) FROM m_user_pending_request r " + where; + final Long totalFilteredRecords = this.jdbcTemplate.queryForObject(countSql, Long.class, args.toArray()); + + final int pageOffset = offset == null ? 0 : offset; + final int pageLimit = limit == null ? 20 : limit; + final String pageSql = SELECT_SQL + where + " ORDER BY r.submitted_date DESC LIMIT ? OFFSET ?"; + final List pageArgs = new ArrayList<>(args); + pageArgs.add(pageLimit); + pageArgs.add(pageOffset); + + final List items = this.jdbcTemplate.query(pageSql, new UserPendingRowMapper(), pageArgs.toArray()); + + return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue()); + } + + @Override + @Transactional(readOnly = true) + public UserPendingRequestData retrievePendingRequest(final Long id) { + final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id); + final String sql = SELECT_SQL + " WHERE r.id = ?"; + return this.jdbcTemplate.queryForObject(sql, new UserPendingDetailRowMapper(), id); + } + + private static class UserPendingRowMapper implements RowMapper { + + @Override + public UserPendingRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long userId = JdbcSupport.getLong(rs, "userId"); + final String requestType = rs.getString("requestType"); + final String status = rs.getString("status"); + final String payloadJson = rs.getString("payloadJson"); + final Long makerId = rs.getLong("makerId"); + final String makerUsername = rs.getString("makerUsername"); + final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class); + final Long checkerId = JdbcSupport.getLong(rs, "checkerId"); + final String checkerUsername = rs.getString("checkerUsername"); + final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class); + final String rejectReason = rs.getString("rejectReason"); + + String username = null; + String firstname = null; + String lastname = null; + String email = null; + String mobileNo = null; + Long officeId = null; + String otpDeliveryMethod = null; + + try { + if (payloadJson != null) { + final JsonObject payloadObj = JsonParser.parseString(payloadJson).getAsJsonObject(); + if (payloadObj.has("username")) { + username = payloadObj.get("username").getAsString(); + } + if (payloadObj.has("firstname")) { + firstname = payloadObj.get("firstname").getAsString(); + } + if (payloadObj.has("lastname")) { + lastname = payloadObj.get("lastname").getAsString(); + } + if (payloadObj.has("email")) { + email = payloadObj.get("email").getAsString(); + } + if (payloadObj.has("mobileNo")) { + mobileNo = payloadObj.get("mobileNo").getAsString(); + } + if (payloadObj.has("officeId")) { + officeId = payloadObj.get("officeId").getAsLong(); + } + if (payloadObj.has("otpDeliveryMethod")) { + otpDeliveryMethod = payloadObj.get("otpDeliveryMethod").getAsString(); + } + } + } catch (Exception ignored) { + } + + return UserPendingRequestData.builder() + .id(id) + .userId(userId) + .requestType(requestType) + .status(status) + .username(username) + .firstname(firstname) + .lastname(lastname) + .email(email) + .mobileNo(mobileNo) + .officeId(officeId) + .otpDeliveryMethod(otpDeliveryMethod) + .makerId(makerId) + .makerUsername(makerUsername) + .submittedDate(submittedDate) + .checkerId(checkerId) + .checkerUsername(checkerUsername) + .checkedDate(checkedDate) + .rejectReason(rejectReason) + .build(); + } + } + + private static class UserPendingDetailRowMapper implements RowMapper { + + @Override + public UserPendingRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long userId = JdbcSupport.getLong(rs, "userId"); + final String requestType = rs.getString("requestType"); + final String status = rs.getString("status"); + final String payloadJson = rs.getString("payloadJson"); + final String originalJson = rs.getString("originalJson"); + final Long makerId = rs.getLong("makerId"); + final String makerUsername = rs.getString("makerUsername"); + final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class); + final Long checkerId = JdbcSupport.getLong(rs, "checkerId"); + final String checkerUsername = rs.getString("checkerUsername"); + final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class); + final String rejectReason = rs.getString("rejectReason"); + + final Map proposedValues = new LinkedHashMap<>(); + final Map originalValues = new LinkedHashMap<>(); + + String username = null; + String firstname = null; + String lastname = null; + String email = null; + String mobileNo = null; + Long officeId = null; + String otpDeliveryMethod = null; + Boolean passwordNeverExpires = null; + + try { + if (payloadJson != null) { + final JsonObject payloadObj = JsonParser.parseString(payloadJson).getAsJsonObject(); + for (final String key : payloadObj.keySet()) { + final JsonElement val = payloadObj.get(key); + if ("password".equals(key) || "repeatPassword".equals(key)) { + proposedValues.put("password", "Password will be set on approval"); + } else if (val.isJsonPrimitive()) { + if (val.getAsJsonPrimitive().isBoolean()) { + proposedValues.put(key, val.getAsBoolean()); + } else if (val.getAsJsonPrimitive().isNumber()) { + proposedValues.put(key, val.getAsNumber()); + } else { + proposedValues.put(key, val.getAsString()); + } + } else if (val.isJsonArray()) { + final List list = new ArrayList<>(); + for (final JsonElement elem : val.getAsJsonArray()) { + list.add(elem.getAsString()); + } + proposedValues.put(key, list); + } + } + + if (proposedValues.containsKey("username")) { + username = (String) proposedValues.get("username"); + } + if (proposedValues.containsKey("firstname")) { + firstname = (String) proposedValues.get("firstname"); + } + if (proposedValues.containsKey("lastname")) { + lastname = (String) proposedValues.get("lastname"); + } + if (proposedValues.containsKey("email")) { + email = (String) proposedValues.get("email"); + } + if (proposedValues.containsKey("mobileNo")) { + mobileNo = (String) proposedValues.get("mobileNo"); + } + if (proposedValues.get("officeId") instanceof Number n) { + officeId = n.longValue(); + } + if (proposedValues.containsKey("otpDeliveryMethod")) { + otpDeliveryMethod = (String) proposedValues.get("otpDeliveryMethod"); + } + if (proposedValues.get("passwordNeverExpires") instanceof Boolean b) { + passwordNeverExpires = b; + } + } + + if (originalJson != null) { + final JsonObject origObj = JsonParser.parseString(originalJson).getAsJsonObject(); + for (final String key : origObj.keySet()) { + final JsonElement val = origObj.get(key); + if (val.isJsonPrimitive()) { + if (val.getAsJsonPrimitive().isBoolean()) { + originalValues.put(key, val.getAsBoolean()); + } else if (val.getAsJsonPrimitive().isNumber()) { + originalValues.put(key, val.getAsNumber()); + } else { + originalValues.put(key, val.getAsString()); + } + } else if (val.isJsonArray()) { + final List list = new ArrayList<>(); + for (final JsonElement elem : val.getAsJsonArray()) { + list.add(elem.getAsString()); + } + originalValues.put(key, list); + } + } + if (proposedValues.containsKey("password")) { + originalValues.put("password", "[PROTECTED]"); + } + } + } catch (Exception ignored) { + } + + return UserPendingRequestData.builder() + .id(id) + .userId(userId) + .requestType(requestType) + .status(status) + .username(username) + .firstname(firstname) + .lastname(lastname) + .email(email) + .mobileNo(mobileNo) + .officeId(officeId) + .otpDeliveryMethod(otpDeliveryMethod) + .passwordNeverExpires(passwordNeverExpires) + .makerId(makerId) + .makerUsername(makerUsername) + .submittedDate(submittedDate) + .checkerId(checkerId) + .checkerUsername(checkerUsername) + .checkedDate(checkedDate) + .rejectReason(rejectReason) + .originalValues(originalValues) + .proposedValues(proposedValues) + .build(); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformService.java new file mode 100644 index 000000000..736b23419 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformService.java @@ -0,0 +1,33 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.service; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface UserPendingWritePlatformService { + + CommandProcessingResult submitCreateUser(JsonCommand command); + + CommandProcessingResult submitUpdateUser(Long userId, JsonCommand command); + + CommandProcessingResult approvePendingRequest(Long id); + + CommandProcessingResult rejectPendingRequest(Long id, JsonCommand command); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java new file mode 100644 index 000000000..00c235802 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java @@ -0,0 +1,386 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; +import org.apache.fineract.organisation.staff.domain.Staff; +import org.apache.fineract.organisation.staff.domain.StaffRepositoryWrapper; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.useradministration.mfa.domain.MfaFactorType; +import org.apache.fineract.useradministration.domain.AppUser; +import org.apache.fineract.useradministration.domain.AppUserRepository; +import org.apache.fineract.useradministration.domain.Role; +import org.apache.fineract.useradministration.domain.RoleRepository; +import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor; +import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository; +import org.apache.fineract.useradministration.domain.UserPendingRequest; +import org.apache.fineract.useradministration.domain.UserPendingRequestRepository; +import org.apache.fineract.useradministration.domain.UserPendingRequestRepositoryWrapper; +import org.apache.fineract.useradministration.domain.UserPendingRequestType; +import org.apache.fineract.useradministration.exception.RoleNotFoundException; +import org.apache.fineract.useradministration.exception.UserNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlatformService { + + private final PlatformSecurityContext context; + private final UserPendingRequestRepositoryWrapper pendingRequestRepositoryWrapper; + private final UserPendingRequestRepository pendingRequestRepository; + private final AppUserRepository appUserRepository; + private final OfficeRepositoryWrapper officeRepositoryWrapper; + private final RoleRepository roleRepository; + private final StaffRepositoryWrapper staffRepositoryWrapper; + private final ClientRepositoryWrapper clientRepositoryWrapper; + private final UserMfaFactorRepository userMfaFactorRepository; + private final ConfigurationDomainService configurationDomainService; + private final PasswordEncoder passwordEncoder; + private final UserDataValidator fromApiJsonDeserializer; + private final FromJsonHelper fromApiJsonHelper; + + @Override + @Transactional + public CommandProcessingResult submitCreateUser(final JsonCommand command) { + final AppUser maker = this.context.authenticatedUser(); + + this.fromApiJsonDeserializer.validateForCreate(command.json()); + + final String username = command.stringValueOfParameterNamed("username"); + final String email = command.stringValueOfParameterNamed("email"); + final String mobileNo = command.stringValueOfParameterNamed("mobileNo"); + + // Pre-flight checks on live users and pending creations + if (this.appUserRepository.findAppUserByName(username) != null) { + throw new PlatformDataIntegrityException("error.msg.user.duplicate.username", + "User with username " + username + " already exists.", "username", username); + } + + final String lockKey = "CREATE_USER:" + username.toLowerCase(); + if (this.pendingRequestRepository.existsByPendingLockKey(lockKey)) { + throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.duplicate", + "A pending creation request for username '" + username + "' is already awaiting checker approval.", username); + } + + assertEmailAndMobileUnique(email, mobileNo, null); + + final JsonObject jsonObject = JsonParser.parseString(command.json()).getAsJsonObject(); + String passwordHash = null; + if (jsonObject.has("password") && !jsonObject.get("password").isJsonNull()) { + final String rawPassword = jsonObject.get("password").getAsString(); + if (!rawPassword.isBlank()) { + passwordHash = this.passwordEncoder.encode(rawPassword); + } + jsonObject.addProperty("password", "[PROTECTED]"); + if (jsonObject.has("repeatPassword")) { + jsonObject.addProperty("repeatPassword", "[PROTECTED]"); + } + } + + final String sanitizedPayload = jsonObject.toString(); + final UserPendingRequest request = UserPendingRequest.create(UserPendingRequestType.CREATE_USER, null, + sanitizedPayload, null, passwordHash, maker.getId(), lockKey); + + final UserPendingRequest saved = this.pendingRequestRepositoryWrapper.saveAndFlush(request); + + return new CommandProcessingResultBuilder() + .withEntityId(saved.getId()) + .setRollbackTransaction(true) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult submitUpdateUser(final Long userId, final JsonCommand command) { + final AppUser maker = this.context.authenticatedUser(); + final AppUser userToUpdate = this.appUserRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException(userId)); + + this.fromApiJsonDeserializer.validateForUpdate(command.json(), maker); + + final String email = command.stringValueOfParameterNamed("email"); + final String mobileNo = command.stringValueOfParameterNamed("mobileNo"); + assertEmailAndMobileUnique(email, mobileNo, userId); + + final String lockKey = "UPDATE_USER:" + userId; + if (this.pendingRequestRepository.existsByPendingLockKey(lockKey)) { + throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.duplicate", + "A pending update request for user ID " + userId + " is already awaiting checker approval.", userId); + } + + // Build point-in-time original JSON snapshot + final Map originalSnapshot = new LinkedHashMap<>(); + originalSnapshot.put("username", userToUpdate.getUsername()); + originalSnapshot.put("firstname", userToUpdate.getFirstname()); + originalSnapshot.put("lastname", userToUpdate.getLastname()); + originalSnapshot.put("email", userToUpdate.getEmail()); + originalSnapshot.put("mobileNo", userToUpdate.getMobileNo()); + originalSnapshot.put("officeId", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getId() : null); + originalSnapshot.put("officeName", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getName() : null); + originalSnapshot.put("staffId", userToUpdate.getStaff() != null ? userToUpdate.getStaff().getId() : null); + originalSnapshot.put("passwordNeverExpires", userToUpdate.getPasswordNeverExpires()); + originalSnapshot.put("isSelfServiceUser", userToUpdate.isSelfServiceUser()); + final Set roleNames = new HashSet<>(); + final Set roleIds = new HashSet<>(); + if (userToUpdate.getRoles() != null) { + for (final Role r : userToUpdate.getRoles()) { + roleNames.add(r.getName()); + roleIds.add(r.getId()); + } + } + originalSnapshot.put("roles", roleIds); + originalSnapshot.put("roleNames", roleNames); + final String originalJson = GoogleGsonSerializerHelper.createSimpleGson().toJson(originalSnapshot); + + final JsonObject jsonObject = JsonParser.parseString(command.json()).getAsJsonObject(); + String passwordHash = null; + if (jsonObject.has("password") && !jsonObject.get("password").isJsonNull()) { + final String rawPassword = jsonObject.get("password").getAsString(); + if (!rawPassword.isBlank()) { + passwordHash = this.passwordEncoder.encode(rawPassword); + } + jsonObject.addProperty("password", "[PROTECTED]"); + if (jsonObject.has("repeatPassword")) { + jsonObject.addProperty("repeatPassword", "[PROTECTED]"); + } + } + + final String sanitizedPayload = jsonObject.toString(); + final UserPendingRequest request = UserPendingRequest.create(UserPendingRequestType.UPDATE_USER, userId, + sanitizedPayload, originalJson, passwordHash, maker.getId(), lockKey); + + final UserPendingRequest saved = this.pendingRequestRepositoryWrapper.saveAndFlush(request); + + return new CommandProcessingResultBuilder() + .withEntityId(saved.getId()) + .setRollbackTransaction(true) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult approvePendingRequest(final Long id) { + final AppUser checker = this.context.authenticatedUser(); + final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id); + + if (!request.isPending()) { + throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.not.pending", + "User pending request " + id + " has already been actioned.", id); + } + + if (request.isMadeBy(checker.getId())) { + throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.self.approval", + "You cannot approve a user request you submitted yourself — a different user must action it.", id); + } + + final Long resourceId; + if (UserPendingRequestType.CREATE_USER.name().equals(request.getRequestType())) { + resourceId = finalizeCreateUser(request); + } else if (UserPendingRequestType.UPDATE_USER.name().equals(request.getRequestType())) { + resourceId = finalizeUpdateUser(request); + } else { + throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.type.invalid", + "Unknown request type: " + request.getRequestType(), request.getRequestType()); + } + + request.approve(checker.getId()); + this.pendingRequestRepositoryWrapper.saveAndFlush(request); + + return new CommandProcessingResultBuilder() + .withEntityId(resourceId) + .withResourceIdAsString(resourceId.toString()) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult rejectPendingRequest(final Long id, final JsonCommand command) { + final AppUser checker = this.context.authenticatedUser(); + final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id); + + if (!request.isPending()) { + throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.not.pending", + "User pending request " + id + " has already been actioned.", id); + } + + if (request.isMadeBy(checker.getId())) { + throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.self.approval", + "You cannot reject a user request you submitted yourself — a different user must action it.", id); + } + + String reason = command != null ? command.stringValueOfParameterNamedAllowingNull("reason") : null; + if (reason == null && command != null) { + reason = command.stringValueOfParameterNamedAllowingNull("rejectReason"); + } + if (reason == null || reason.isBlank()) { + throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.reject.reason.required", + "A reason is required to reject a user pending request.", id); + } + + request.reject(checker.getId(), reason); + this.pendingRequestRepositoryWrapper.saveAndFlush(request); + + return new CommandProcessingResultBuilder() + .withEntityId(request.getId()) + .withResourceIdAsString(request.getId().toString()) + .build(); + } + + private Long finalizeCreateUser(final UserPendingRequest request) { + final JsonElement parsedElement = this.fromApiJsonHelper.parse(request.getPayloadJson()); + final JsonCommand replay = JsonCommand.fromJsonElement(null, parsedElement, this.fromApiJsonHelper); + + final Long officeId = replay.longValueOfParameterNamed("officeId"); + final Office userOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId); + + final String[] roles = replay.arrayValueOfParameterNamed("roles"); + final Set allRoles = assembleSetOfRoles(roles); + + final Long staffId = replay.longValueOfParameterNamed("staffId"); + final Staff linkedStaff = staffId != null + ? this.staffRepositoryWrapper.findByOfficeWithNotFoundDetection(staffId, userOffice.getId()) + : null; + + final Collection clients = null; + + final AppUser appUser = AppUser.fromJson(userOffice, linkedStaff, allRoles, clients, replay); + + if (this.configurationDomainService.isForcePasswordResetOnFirstLoginEnabled()) { + appUser.updatePasswordResetRequired(true); + } + + // Set pre-hashed password + if (request.getPasswordHash() != null) { + appUser.updatePassword(request.getPasswordHash()); + } + + this.appUserRepository.saveAndFlush(appUser); + + // MFA Enrollment + final String otpDeliveryMethod = replay.stringValueOfParameterNamed("otpDeliveryMethod"); + if (otpDeliveryMethod != null) { + final MfaFactorType factorType = MfaFactorType.fromString(otpDeliveryMethod); + final UserMfaFactor factor = new UserMfaFactor(); + factor.setAppUser(appUser); + factor.setFactorType(factorType); + factor.setDestination(factorType == MfaFactorType.SMS ? appUser.getMobileNo() : appUser.getEmail()); + factor.setVerified(true); + factor.setVerifiedOn(OffsetDateTime.now(ZoneOffset.UTC)); + this.userMfaFactorRepository.save(factor); + } + + return appUser.getId(); + } + + private Long finalizeUpdateUser(final UserPendingRequest request) { + final AppUser userToUpdate = this.appUserRepository.findById(request.getUserId()) + .orElseThrow(() -> new UserNotFoundException(request.getUserId())); + + final JsonElement parsedElement = this.fromApiJsonHelper.parse(request.getPayloadJson()); + final JsonCommand replay = JsonCommand.fromJsonElement(request.getUserId(), parsedElement, this.fromApiJsonHelper); + + final Map changes = userToUpdate.update(replay, null, null); + + if (changes.containsKey("officeId")) { + final Long officeId = (Long) changes.get("officeId"); + final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId); + userToUpdate.changeOffice(office); + } + if (changes.containsKey("staffId")) { + final Long staffId = (Long) changes.get("staffId"); + Staff linkedStaff = null; + if (staffId != null) { + linkedStaff = this.staffRepositoryWrapper.findByOfficeWithNotFoundDetection(staffId, userToUpdate.getOffice().getId()); + } + userToUpdate.changeStaff(linkedStaff); + } + if (changes.containsKey("roles")) { + final String[] roleIds = (String[]) changes.get("roles"); + final Set allRoles = assembleSetOfRoles(roleIds); + userToUpdate.updateRoles(allRoles); + } + if (request.getPasswordHash() != null) { + userToUpdate.updatePassword(request.getPasswordHash()); + } + + this.appUserRepository.saveAndFlush(userToUpdate); + return userToUpdate.getId(); + } + + private Set assembleSetOfRoles(final String[] rolesArray) { + final Set allRoles = new HashSet<>(); + if (!ObjectUtils.isEmpty(rolesArray)) { + for (final String roleId : rolesArray) { + final Long id = Long.valueOf(roleId); + final Role role = this.roleRepository.findById(id).orElseThrow(() -> new RoleNotFoundException(id)); + allRoles.add(role); + } + } + return allRoles; + } + + private void assertEmailAndMobileUnique(final String email, final String mobileNo, final Long excludeUserId) { + if (email != null && !email.isBlank()) { + final java.util.Optional emailOwner = excludeUserId == null + ? this.appUserRepository.findByEmailIgnoreCase(email) + : this.appUserRepository.findByEmailIgnoreCaseAndIdNot(email, excludeUserId); + if (emailOwner.isPresent()) { + throw new PlatformDataIntegrityException("error.msg.user.duplicate.email", + "User with email " + email + " already exists.", "email", email); + } + } + if (mobileNo != null && !mobileNo.isBlank()) { + final java.util.Optional mobileOwner = excludeUserId == null + ? this.appUserRepository.findByMobileNo(mobileNo) + : this.appUserRepository.findByMobileNoAndIdNot(mobileNo, excludeUserId); + if (mobileOwner.isPresent()) { + throw new PlatformDataIntegrityException("error.msg.user.duplicate.mobile", + "User with mobile number " + mobileNo + " already exists.", "mobileNo", mobileNo); + } + } + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 8fd705c9f..7a7b339af 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -543,4 +543,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_enable_admin_user_maker_checker.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_enable_admin_user_maker_checker.xml new file mode 100644 index 000000000..9ddde27a5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_enable_admin_user_maker_checker.xml @@ -0,0 +1,77 @@ + + + + + ANY + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'authorisation', 'PERMISSIONS_ROLE_CHECKER', 'ROLE', 'PERMISSIONS_CHECKER', FALSE + WHERE NOT EXISTS ( + SELECT 1 FROM m_permission WHERE code = 'PERMISSIONS_ROLE_CHECKER' + ); + + + + + ANY + + + SELECT COUNT(*) + FROM m_permission + WHERE code IN ( + 'CREATE_USER', 'UPDATE_USER', 'DELETE_USER', + 'CREATE_ROLE', 'UPDATE_ROLE', 'PERMISSIONS_ROLE', + 'DELETE_ROLE', 'ENABLE_ROLE', 'DISABLE_ROLE' + ) + + + + UPDATE m_permission + SET can_maker_checker = TRUE + WHERE code IN ( + 'CREATE_USER', 'UPDATE_USER', 'DELETE_USER', + 'CREATE_ROLE', 'UPDATE_ROLE', 'PERMISSIONS_ROLE', + 'DELETE_ROLE', 'ENABLE_ROLE', 'DISABLE_ROLE' + ); + + + + + ANY + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE r.name = 'Super user' + AND p.code IN ( + 'CREATE_USER_CHECKER', 'UPDATE_USER_CHECKER', 'DELETE_USER_CHECKER', + 'CREATE_ROLE_CHECKER', 'UPDATE_ROLE_CHECKER', 'PERMISSIONS_ROLE_CHECKER', + 'DELETE_ROLE_CHECKER', 'ENABLE_ROLE_CHECKER', 'DISABLE_ROLE_CHECKER' + ) + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT ur.role_id, p.id + FROM m_appuser u + JOIN m_appuser_role ur ON ur.appuser_id = u.id + CROSS JOIN m_permission p + WHERE u.username = 'bankadmin' + AND p.code IN ( + 'CREATE_USER_CHECKER', 'UPDATE_USER_CHECKER', 'DELETE_USER_CHECKER', + 'CREATE_ROLE_CHECKER', 'UPDATE_ROLE_CHECKER', 'PERMISSIONS_ROLE_CHECKER', + 'DELETE_ROLE_CHECKER', 'ENABLE_ROLE_CHECKER', 'DISABLE_ROLE_CHECKER' + ) + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id + ); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_add_user_pending_request_and_enable_charge_mc.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_add_user_pending_request_and_enable_charge_mc.xml new file mode 100644 index 000000000..9d0c6db5a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_add_user_pending_request_and_enable_charge_mc.xml @@ -0,0 +1,98 @@ + + + + + ANY + + CREATE TABLE IF NOT EXISTS m_user_pending_request ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NULL REFERENCES m_appuser(id) ON DELETE SET NULL, + request_type VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL, + payload_json TEXT NOT NULL, + original_json TEXT NULL, + password_hash VARCHAR(255) NULL, + maker_id BIGINT NOT NULL REFERENCES m_appuser(id), + submitted_date TIMESTAMPTZ NOT NULL, + checker_id BIGINT NULL REFERENCES m_appuser(id), + checked_date TIMESTAMPTZ NULL, + reject_reason VARCHAR(500) NULL, + pending_lock_key VARCHAR(100) NULL UNIQUE + ); + + CREATE INDEX IF NOT EXISTS idx_user_pending_req_status ON m_user_pending_request(status); + CREATE INDEX IF NOT EXISTS idx_user_pending_req_user_id ON m_user_pending_request(user_id); + CREATE INDEX IF NOT EXISTS idx_user_pending_req_maker ON m_user_pending_request(maker_id); + + + + + ANY + + -- Disable native MC on CREATE_USER and UPDATE_USER to avoid core password sanitization error + UPDATE m_permission + SET can_maker_checker = FALSE + WHERE code IN ('CREATE_USER', 'UPDATE_USER'); + + -- Enable native MC on UPDATE_CHARGE and DELETE_CHARGE + UPDATE m_permission + SET can_maker_checker = TRUE + WHERE code IN ('UPDATE_CHARGE', 'DELETE_CHARGE'); + + -- Insert bespoke user maker-checker permissions + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'user_administration', 'SUBMIT_CREATE_USER', 'USER', 'SUBMIT_CREATE', FALSE + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'SUBMIT_CREATE_USER'); + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'user_administration', 'SUBMIT_UPDATE_USER', 'USER', 'SUBMIT_UPDATE', FALSE + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'SUBMIT_UPDATE_USER'); + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'user_administration', 'APPROVE_USER', 'USER', 'APPROVE', FALSE + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'APPROVE_USER'); + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'user_administration', 'REJECT_USER', 'USER', 'REJECT', FALSE + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REJECT_USER'); + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'user_administration', 'READ_USER_PENDING_REQUEST', 'USER', 'READ_PENDING', FALSE + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_USER_PENDING_REQUEST'); + + -- Grant permissions to Super user + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE r.name = 'Super user' + AND p.code IN ( + 'UPDATE_CHARGE', 'UPDATE_CHARGE_CHECKER', 'DELETE_CHARGE', 'DELETE_CHARGE_CHECKER', + 'SUBMIT_CREATE_USER', 'SUBMIT_UPDATE_USER', 'APPROVE_USER', 'REJECT_USER', 'READ_USER_PENDING_REQUEST' + ) + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + + -- Grant permissions to bankadmin + INSERT INTO m_role_permission (role_id, permission_id) + SELECT ur.role_id, p.id + FROM m_appuser u + JOIN m_appuser_role ur ON ur.appuser_id = u.id + CROSS JOIN m_permission p + WHERE u.username = 'bankadmin' + AND p.code IN ( + 'UPDATE_CHARGE', 'UPDATE_CHARGE_CHECKER', 'DELETE_CHARGE', 'DELETE_CHARGE_CHECKER', + 'SUBMIT_CREATE_USER', 'SUBMIT_UPDATE_USER', 'APPROVE_USER', 'REJECT_USER', 'READ_USER_PENDING_REQUEST' + ) + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id + ); + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java index 2e34d1fe0..c4db9dadf 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java @@ -484,6 +484,55 @@ public class CommandSourceServiceTest { Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); } + @Test + public void testProcessCommand_FixedDepositProduct_SuperUser_EnforcesMakerChecker() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser superUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_FIXEDDEPOSITPRODUCT"); + Mockito.when(commandSource.getEntityName()).thenReturn("FIXEDDEPOSITPRODUCT"); + Mockito.when(commandSource.getActionName()).thenReturn("CREATE"); + Mockito.when(commandSource.getId()).thenReturn(114L); + Mockito.when(commandSource.getResourceId()).thenReturn(214L); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_FIXEDDEPOSITPRODUCT")).thenReturn(true); + Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + + @Test + public void testProcessCommand_FixedDepositProduct_CheckerApproval_MarksAsChecked() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser checkerUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_FIXEDDEPOSITPRODUCT"); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_FIXEDDEPOSITPRODUCT")).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand, + commandSource, checkerUser, true); + + Assertions.assertNotNull(result); + Mockito.verify(commandSource).markAsChecked(checkerUser); + Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval(); + } + @Test public void testProcessCommand_Products_CheckerApproval_MarksAsChecked() { NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); @@ -505,4 +554,88 @@ public class CommandSourceServiceTest { Mockito.verify(commandSource).markAsChecked(checkerUser); Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval(); } + + @Test + public void testProcessCommand_UserOperations_SuperUserBypassBlocked_ThrowsRollbackException() { + String[] actions = { "CREATE", "UPDATE", "DELETE" }; + String[] permissions = { "CREATE_USER", "UPDATE_USER", "DELETE_USER" }; + + for (int i = 0; i < actions.length; i++) { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser superUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getEntityName()).thenReturn("USER"); + Mockito.when(commandSource.getActionName()).thenReturn(actions[i]); + Mockito.when(commandSource.getPermissionCode()).thenReturn(permissions[i]); + Mockito.when(commandSource.isSanitized()).thenReturn(false); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask(permissions[i])).thenReturn(true); + Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + } + + @Test + public void testProcessCommand_RoleOperations_SuperUserBypassBlocked_ThrowsRollbackException() { + String[] actions = { "CREATE", "UPDATE", "PERMISSIONS", "DELETE" }; + String[] permissions = { "CREATE_ROLE", "UPDATE_ROLE", "PERMISSIONS_ROLE", "DELETE_ROLE" }; + + for (int i = 0; i < actions.length; i++) { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser superUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getEntityName()).thenReturn("ROLE"); + Mockito.when(commandSource.getActionName()).thenReturn(actions[i]); + Mockito.when(commandSource.getPermissionCode()).thenReturn(permissions[i]); + Mockito.when(commandSource.isSanitized()).thenReturn(false); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask(permissions[i])).thenReturn(true); + Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + } + + @Test + public void testProcessCommand_UserAndRole_CheckerApproval_MarksAsChecked() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser checkerUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_USER"); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_USER")).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand, + commandSource, checkerUser, true); + + Assertions.assertNotNull(result); + Mockito.verify(commandSource).markAsChecked(checkerUser); + Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval(); + } }