[FINERACT-1678] Reject write API calls for hard-locked loan accounts

This commit is contained in:
taskain7
2022-10-28 14:04:46 +02:00
committed by Arnold Gálovics
parent e76adfbe56
commit 7be193dd0b
7 changed files with 317 additions and 8 deletions
@@ -24,4 +24,6 @@ import org.apache.fineract.cob.domain.LoanAccountLock;
public interface LoanAccountLockService {
List<LoanAccountLock> getLockedLoanAccountByPage(int page, int limit);
boolean isLoanHardLocked(Long loanId);
}
@@ -19,9 +19,12 @@
package org.apache.fineract.cob.service;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.cob.domain.LoanAccountLock;
import org.apache.fineract.cob.domain.LoanAccountLockRepository;
import org.apache.fineract.cob.domain.LockOwner;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
@@ -39,4 +42,13 @@ public class LoanAccountLockServiceImpl implements LoanAccountLockService {
Page<LoanAccountLock> loanAccountLocks = loanAccountLockRepository.findAll(loanAccountLockPage);
return loanAccountLocks.getContent();
}
@Override
public boolean isLoanHardLocked(Long loanId) {
Optional<LoanAccountLock> loanAccountLockOptional = loanAccountLockRepository.findById(loanId);
return loanAccountLockOptional //
.filter(loanAccountLock -> LockOwner.LOAN_COB_CHUNK_PROCESSING.equals(loanAccountLock.getLockOwner()) //
|| LockOwner.LOAN_INLINE_COB_PROCESSING.equals(loanAccountLock.getLockOwner())) //
.filter(loanAccountLock -> StringUtils.isBlank(loanAccountLock.getError())).isPresent();
}
}
@@ -20,6 +20,7 @@
package org.apache.fineract.infrastructure.core.config;
import org.apache.fineract.infrastructure.instancemode.filter.FineractInstanceModeApiFilter;
import org.apache.fineract.infrastructure.jobs.filter.LoanCOBApiFilter;
import org.apache.fineract.infrastructure.security.filter.TenantAwareBasicAuthenticationFilter;
import org.apache.fineract.infrastructure.security.filter.TwoFactorAuthenticationFilter;
import org.apache.fineract.infrastructure.security.service.TenantAwareJpaPlatformUserDetailsService;
@@ -56,6 +57,8 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private FineractInstanceModeApiFilter fineractInstanceModeApiFilter;
@Autowired
private LoanCOBApiFilter loanCOBApiFilter;
@Autowired
private FineractProperties fineractProperties;
@@ -86,6 +89,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
.and() //
.addFilterAfter(fineractInstanceModeApiFilter, SecurityContextPersistenceFilter.class) //
.addFilterAfter(tenantAwareBasicAuthenticationFilter(), FineractInstanceModeApiFilter.class) //
.addFilterAfter(loanCOBApiFilter, TenantAwareBasicAuthenticationFilter.class) //
.addFilterAfter(twoFactorAuthenticationFilter, BasicAuthenticationFilter.class); //
if (serverProperties.getSsl().isEnabled()) {
@@ -89,6 +89,16 @@ public class ApiGlobalErrorResponse {
return globalErrorResponse;
}
public static ApiGlobalErrorResponse loanIsLocked(final Long loanId) {
final ApiGlobalErrorResponse globalErrorResponse = new ApiGlobalErrorResponse();
globalErrorResponse.setHttpStatusCode(Status.CONFLICT.toString());
globalErrorResponse.setDeveloperMessage("Loan is locked by the COB job. Loan ID: " + loanId);
globalErrorResponse.setUserMessageGlobalisationCode("error.msg.loan.locked");
globalErrorResponse.setDefaultUserMessage("Loan is locked by the COB job. Loan ID: \" + loanId");
return globalErrorResponse;
}
public static ApiGlobalErrorResponse unAuthorized(final String defaultUserMessage) {
final ApiGlobalErrorResponse globalErrorResponse = new ApiGlobalErrorResponse();
globalErrorResponse.setHttpStatusCode("403");
@@ -0,0 +1,133 @@
/**
* 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.infrastructure.jobs.filter;
import com.google.common.base.Splitter;
import com.sun.research.ws.wadl.HTTPMethods;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.cob.service.LoanAccountLockService;
import org.apache.fineract.infrastructure.core.data.ApiGlobalErrorResponse;
import org.apache.fineract.portfolio.loanaccount.domain.GLIMAccountInfoRepository;
import org.apache.fineract.portfolio.loanaccount.domain.GroupLoanIndividualMonitoringAccount;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
@RequiredArgsConstructor
public class LoanCOBApiFilter extends OncePerRequestFilter {
private final GLIMAccountInfoRepository glimAccountInfoRepository;
private final LoanAccountLockService loanAccountLockService;
private static final List<HTTPMethods> HTTP_METHODS = List.of(HTTPMethods.POST, HTTPMethods.PUT, HTTPMethods.DELETE);
private static final Function<String, Boolean> URL_FUNCTION = s -> s.matches("/loans/\\d+.*") || s.matches("/loans/glimAccount/\\d+.*");
private static final Integer LOAN_ID_INDEX_IN_URL = 2;
private static final Integer GLIM_ID_INDEX_IN_URL = 3;
private static final Integer GLIM_STRING_INDEX_IN_URL = 2;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (!isOnApiList(request)) {
proceed(filterChain, request, response);
} else {
Iterable<String> split = Splitter.on('/').split(request.getPathInfo());
Supplier<Stream<String>> streamSupplier = () -> StreamSupport.stream(split.spliterator(), false);
boolean isGlim = isGlim(streamSupplier);
Long loanId = getLoanId(isGlim, streamSupplier);
if (isLoanLocked(loanId, isGlim)) {
reject(loanId, response);
} else {
proceed(filterChain, request, response);
}
}
}
private boolean isLoanLocked(Long loanId, boolean isGlim) {
if (!isGlim) {
return loanAccountLockService.isLoanHardLocked(loanId);
} else {
GroupLoanIndividualMonitoringAccount glimAccount = glimAccountInfoRepository.findOneByIsAcceptingChildAndApplicationId(true,
BigDecimal.valueOf(loanId));
if (glimAccount != null) {
Set<Loan> loans = glimAccount.getChildLoan();
List<Long> loanIds = loans.stream().map(Loan::getId).toList();
return loanIds.stream().anyMatch(loanAccountLockService::isLoanHardLocked);
} else {
return false;
}
}
}
private void proceed(FilterChain filterChain, HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
filterChain.doFilter(request, response);
}
private void reject(Long loanId, HttpServletResponse response) throws IOException {
response.setStatus(HttpStatus.SC_CONFLICT);
ApiGlobalErrorResponse errorResponse = ApiGlobalErrorResponse.loanIsLocked(loanId);
response.getWriter().write(errorResponse.toJson());
}
private Long getLoanId(boolean isGlim, Supplier<Stream<String>> streamSupplier) {
if (!isGlim) {
if (streamSupplier.get().count() >= LOAN_ID_INDEX_IN_URL + 1) {
return Long.valueOf(streamSupplier.get().skip(LOAN_ID_INDEX_IN_URL).findFirst().get());
} else {
return null;
}
} else {
if (streamSupplier.get().count() >= GLIM_ID_INDEX_IN_URL + 1) {
return Long.valueOf(streamSupplier.get().skip(GLIM_ID_INDEX_IN_URL).findFirst().get());
} else {
return null;
}
}
}
private boolean isOnApiList(HttpServletRequest request) {
if (StringUtils.isBlank(request.getPathInfo())) {
return false;
}
return HTTP_METHODS.contains(HTTPMethods.fromValue(request.getMethod())) && URL_FUNCTION.apply(request.getPathInfo());
}
private boolean isGlim(Supplier<Stream<String>> streamSupplier) {
if (streamSupplier.get().count() >= GLIM_STRING_INDEX_IN_URL + 1) {
return "glimAccount".equals(streamSupplier.get().skip(GLIM_STRING_INDEX_IN_URL).findFirst().get());
}
return false;
}
}
@@ -39,6 +39,9 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
import org.apache.fineract.cob.domain.LoanAccountLockRepository;
import org.apache.fineract.cob.exceptions.LoanAccountLockCannotBeOverruledException;
import org.apache.fineract.cob.service.LoanAccountLockService;
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrapper;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
@@ -278,6 +281,8 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
private final PostDatedChecksRepository postDatedChecksRepository;
private final LoanDisbursementDetailsRepository loanDisbursementDetailsRepository;
private final LoanRepaymentScheduleInstallmentRepository loanRepaymentScheduleInstallmentRepository;
private final LoanAccountLockRepository loanAccountLockRepository;
private final LoanAccountLockService loanAccountLockService;
private static boolean isPartOfThisInstallment(LoanCharge loanCharge, LoanRepaymentScheduleInstallment e) {
return e.getFromDate().isBefore(loanCharge.getDueDate()) && !loanCharge.getDueDate().isAfter(e.getDueDate());
@@ -2551,20 +2556,28 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
final Staff fromLoanOfficer = this.loanAssembler.findLoanOfficerByIdIfProvided(fromLoanOfficerId);
final Staff toLoanOfficer = this.loanAssembler.findLoanOfficerByIdIfProvided(toLoanOfficerId);
List<Long> lockedLoanIds = new ArrayList<>();
for (final String loanIdString : loanIds) {
final Long loanId = Long.valueOf(loanIdString);
final Loan loan = this.loanAssembler.assembleFrom(loanId);
businessEventNotifierService.notifyPreBusinessEvent(new LoanReassignOfficerBusinessEvent(loan));
checkClientOrGroupActive(loan);
if (loanAccountLockService.isLoanHardLocked(loanId)) {
lockedLoanIds.add(loanId);
} else {
businessEventNotifierService.notifyPreBusinessEvent(new LoanReassignOfficerBusinessEvent(loan));
checkClientOrGroupActive(loan);
if (!loan.hasLoanOfficer(fromLoanOfficer)) {
throw new LoanOfficerAssignmentException(loanId, fromLoanOfficerId);
if (!loan.hasLoanOfficer(fromLoanOfficer)) {
throw new LoanOfficerAssignmentException(loanId, fromLoanOfficerId);
}
loan.reassignLoanOfficer(toLoanOfficer, dateOfLoanOfficerAssignment);
saveLoanWithDataIntegrityViolationChecks(loan);
businessEventNotifierService.notifyPostBusinessEvent(new LoanReassignOfficerBusinessEvent(loan));
}
loan.reassignLoanOfficer(toLoanOfficer, dateOfLoanOfficerAssignment);
saveLoanWithDataIntegrityViolationChecks(loan);
businessEventNotifierService.notifyPostBusinessEvent(new LoanReassignOfficerBusinessEvent(loan));
}
if (!lockedLoanIds.isEmpty()) {
throw new LoanAccountLockCannotBeOverruledException("There are hard-lcoked loan accounts: " + lockedLoanIds);
}
this.loanRepositoryWrapper.flush();
@@ -0,0 +1,135 @@
/**
* 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.infrastructure.jobs.filter;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import com.sun.research.ws.wadl.HTTPMethods;
import java.io.IOException;
import java.io.PrintWriter;
import java.math.BigDecimal;
import java.util.Collections;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import org.apache.fineract.cob.service.LoanAccountLockService;
import org.apache.fineract.portfolio.loanaccount.domain.GLIMAccountInfoRepository;
import org.apache.fineract.portfolio.loanaccount.domain.GroupLoanIndividualMonitoringAccount;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.http.HttpStatus;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class LoanCOBApiFilterTest {
@InjectMocks
private LoanCOBApiFilter testObj;
@Mock
private LoanAccountLockService loanAccountLockService;
@Mock
private GLIMAccountInfoRepository glimAccountInfoRepository;
@Test
void shouldProceedWhenUrlDoesNotMatch() throws ServletException, IOException {
MockHttpServletRequest request = mock(MockHttpServletRequest.class);
given(request.getPathInfo()).willReturn("/jobs/2/inline");
given(request.getMethod()).willReturn(HTTPMethods.POST.value());
MockHttpServletResponse response = mock(MockHttpServletResponse.class);
FilterChain filterChain = mock(FilterChain.class);
testObj.doFilterInternal(request, response, filterChain);
verify(filterChain, times(1)).doFilter(request, response);
}
@Test
void shouldProceedWhenLoanIsNotLocked() throws ServletException, IOException {
MockHttpServletRequest request = mock(MockHttpServletRequest.class);
MockHttpServletResponse response = mock(MockHttpServletResponse.class);
FilterChain filterChain = mock(FilterChain.class);
given(request.getPathInfo()).willReturn("/loans/2/charges");
given(request.getMethod()).willReturn(HTTPMethods.POST.value());
given(loanAccountLockService.isLoanHardLocked(2L)).willReturn(false);
testObj.doFilterInternal(request, response, filterChain);
verify(filterChain, times(1)).doFilter(request, response);
}
@Test
void shouldProceedWhenLoanIsSoftLocked() throws ServletException, IOException {
MockHttpServletRequest request = mock(MockHttpServletRequest.class);
MockHttpServletResponse response = mock(MockHttpServletResponse.class);
FilterChain filterChain = mock(FilterChain.class);
given(request.getPathInfo()).willReturn("/loans/2/charges");
given(request.getMethod()).willReturn(HTTPMethods.POST.value());
given(loanAccountLockService.isLoanHardLocked(2L)).willReturn(false);
testObj.doFilterInternal(request, response, filterChain);
verify(filterChain, times(1)).doFilter(request, response);
}
@Test
void shouldRejectWhenLoanIsHardLocked() throws ServletException, IOException {
MockHttpServletRequest request = mock(MockHttpServletRequest.class);
MockHttpServletResponse response = mock(MockHttpServletResponse.class);
FilterChain filterChain = mock(FilterChain.class);
PrintWriter writer = mock(PrintWriter.class);
given(request.getPathInfo()).willReturn("/loans/2/charges");
given(request.getMethod()).willReturn(HTTPMethods.POST.value());
given(loanAccountLockService.isLoanHardLocked(2L)).willReturn(true);
given(response.getWriter()).willReturn(writer);
testObj.doFilterInternal(request, response, filterChain);
verify(response, times(1)).setStatus(HttpStatus.SC_CONFLICT);
}
@Test
void shouldRejectWhenGlimLoanIsHardLocked() throws ServletException, IOException {
MockHttpServletRequest request = mock(MockHttpServletRequest.class);
MockHttpServletResponse response = mock(MockHttpServletResponse.class);
FilterChain filterChain = mock(FilterChain.class);
PrintWriter writer = mock(PrintWriter.class);
GroupLoanIndividualMonitoringAccount glimAccount = mock(GroupLoanIndividualMonitoringAccount.class);
Loan loan = mock(Loan.class);
Long loanId = 2L;
given(request.getPathInfo()).willReturn("/loans/glimAccount/2");
given(request.getMethod()).willReturn(HTTPMethods.POST.value());
given(glimAccountInfoRepository.findOneByIsAcceptingChildAndApplicationId(true, BigDecimal.valueOf(2))).willReturn(glimAccount);
given(glimAccount.getChildLoan()).willReturn(Collections.singleton(loan));
given(loan.getId()).willReturn(loanId);
given(loanAccountLockService.isLoanHardLocked(loanId)).willReturn(true);
given(response.getWriter()).willReturn(writer);
testObj.doFilterInternal(request, response, filterChain);
verify(response, times(1)).setStatus(HttpStatus.SC_CONFLICT);
}
}