FINERACT-2461: Replace SQL string concatenation with parameterized queries

Fix ~30 SQL injection vulnerabilities across datatable services by
replacing string concatenation with JDBC parameterized queries (?).

Files changed:
- DatatableWriteServiceImpl: parameterized DELETE, INSERT, SELECT queries
- DatatableUtil: parameterized dataScopedSQL, office join conditions
- GenericDataService/Impl: added parameterized fillResultsetRowData overload
- DatatableReadServiceImpl: parameterized countDatatableEntries
- WriteSurveyServiceImpl: removed duplicate vulnerable getPermissionSql
- DepositAccountReadPlatformServiceImpl: added pagination validation

Tests added:
- DatatableUtilTest: 11 tests for parameterized dataScopedSQL and joins
- DatatableWriteServiceImplTest: 7 tests for parameterized CRUD queries
- DatatableReadServiceImplCountTest: 1 test for parameterized count query

Do good with AI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
cam
2026-02-17 11:51:55 +00:00
committed by Adam Saghy
co-authored by Claude Opus 4.6
parent 87e328e9e7
commit 054d18b020
10 changed files with 706 additions and 139 deletions
@@ -31,6 +31,8 @@ public interface GenericDataService {
List<ResultsetRowData> fillResultsetRowData(String sql, List<ResultsetColumnHeaderData> columnHeaders);
List<ResultsetRowData> fillResultsetRowData(String sql, List<ResultsetColumnHeaderData> columnHeaders, Object... args);
String generateJsonFromGenericResultsetData(GenericResultsetData grs);
String replace(String str, String pattern, String replace);
@@ -263,8 +263,8 @@ public class DatatableReadServiceImpl implements DatatableReadService {
@Override
public Long countDatatableEntries(final String datatableName, final Long appTableId, String foreignKeyColumn) {
final String sqlString = "SELECT COUNT(" + sqlGenerator.escape(foreignKeyColumn) + ") FROM " + sqlGenerator.escape(datatableName)
+ " WHERE " + sqlGenerator.escape(foreignKeyColumn) + " = " + appTableId;
return this.jdbcTemplate.queryForObject(sqlString, Long.class); // NOSONAR
+ " WHERE " + sqlGenerator.escape(foreignKeyColumn) + " = ?";
return this.jdbcTemplate.queryForObject(sqlString, Long.class, appTableId); // NOSONAR
}
@Override
@@ -23,6 +23,7 @@ import static org.apache.fineract.infrastructure.dataqueries.api.DataTableApiCon
import static org.apache.fineract.infrastructure.dataqueries.api.DataTableApiConstant.TABLE_FIELD_ID;
import static org.apache.fineract.infrastructure.dataqueries.api.DataTableApiConstant.TABLE_REGISTERED_TABLE;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -112,9 +113,10 @@ public class DatatableUtil {
}
public CommandProcessingResult checkMainResourceExistsWithinScope(@NonNull EntityTables entityTable, final Long appTableId) {
final String sql = dataScopedSQL(entityTable, appTableId);
final List<Object> params = new ArrayList<>();
final String sql = dataScopedSQL(entityTable, appTableId, params);
log.debug("data scoped sql: {}", sql);
final SqlRowSet rs = this.jdbcTemplate.queryForRowSet(sql);
final SqlRowSet rs = this.jdbcTemplate.queryForRowSet(sql, params.toArray());
if (!rs.next()) {
throw new DatatableNotFoundException(entityTable, appTableId);
@@ -140,60 +142,88 @@ public class DatatableUtil {
.build();
}
public String dataScopedSQL(@NonNull EntityTables entityTable, final Long appTableId) {
public String dataScopedSQL(@NonNull EntityTables entityTable, final Long appTableId, final List<Object> params) {
// unfortunately have to, one way or another, be able to restrict data to the users office hierarchy. Here, a
// few key tables are done. But if additional fields are needed on other tables the same pattern applies
final AppUser currentUser = this.context.authenticatedUser();
String officeHierarchy = currentUser.getOffice().getHierarchy();
String hierarchyPattern = officeHierarchy + "%";
// m_loan and m_savings_account are connected to an m_office through either an m_client or an m_group If both it
// means it relates to an m_client that is in a group (still an m_client account)
return switch (entityTable) {
case LOAN -> "select distinct x.* from ( "
+ "(select o.id as officeId, l.group_id as groupId, l.client_id as clientId, null as savingsId, l.id as loanId, null as transactionId, null as entityId from m_loan l "
+ getClientOfficeJoinCondition(officeHierarchy, "l") + " where l.id = " + appTableId + ")" + " union all "
+ "(select o.id as officeId, l.group_id as groupId, l.client_id as clientId, null as savingsId, l.id as loanId, null as transactionId, null as entityId from m_loan l "
+ getGroupOfficeJoinCondition(officeHierarchy, "l") + " where l.id = " + appTableId + ")" + " ) as x";
case SAVINGS -> "select distinct x.* from ( "
+ "(select o.id as officeId, s.group_id as groupId, s.client_id as clientId, s.id as savingsId, null as loanId, null as transactionId, null as entityId "
+ "from m_savings_account s " + getClientOfficeJoinCondition(officeHierarchy, "s") + " where s.id = " + appTableId + ")"
+ " union all "
+ "(select o.id as officeId, s.group_id as groupId, s.client_id as clientId, s.id as savingsId, null as loanId, null as transactionId, null as entityId "
+ "from m_savings_account s " + getGroupOfficeJoinCondition(officeHierarchy, "s") + " where s.id = " + appTableId + ")"
+ " ) as x";
case SAVINGS_TRANSACTION -> "select distinct x.* from ( "
+ "(select o.id as officeId, s.group_id as groupId, s.client_id as clientId, s.id as savingsId, null as loanId, t.id as transactionId, null as entityId "
+ "from m_savings_account_transaction t join m_savings_account s on t.savings_account_id = s.id "
+ getClientOfficeJoinCondition(officeHierarchy, "s") + " where t.id = " + appTableId + ")" + " union all "
+ "(select o.id as officeId, s.group_id as groupId, s.client_id as clientId, s.id as savingsId, null as loanId, t.id as transactionId, null as entityId "
+ "from m_savings_account_transaction t join m_savings_account s on t.savings_account_id = s.id "
+ getGroupOfficeJoinCondition(officeHierarchy, "s") + " where t.id = " + appTableId + ")" + " ) as x";
case CLIENT ->
"select o.id as officeId, null as groupId, c.id as clientId, null as savingsId, null as loanId, null as transactionId, null as entityId from m_client c "
+ getOfficeJoinCondition(officeHierarchy, "c") + " where c.id = " + appTableId;
case GROUP, CENTER ->
"select o.id as officeId, g.id as groupId, null as clientId, null as savingsId, null as loanId, null as transactionId, null as entityId from m_group g "
+ getOfficeJoinCondition(officeHierarchy, "g") + " where g.id = " + appTableId;
case OFFICE ->
"select o.id as officeId, null as groupId, null as clientId, null as savingsId, null as loanId, null as transactionId, null as entityId from m_office o "
+ "where o.hierarchy like '" + officeHierarchy + "%'" + " and o.id = " + appTableId;
case LOAN_PRODUCT, SAVINGS_PRODUCT, SHARE_PRODUCT ->
"select null as officeId, null as groupId, null as clientId, null as savingsId, null as loanId, null as transactionId, p.id as entityId from "
+ entityTable.getName() + " as p WHERE p.id = " + appTableId;
case LOAN -> {
params.add(hierarchyPattern);
params.add(appTableId);
params.add(hierarchyPattern);
params.add(appTableId);
yield "select distinct x.* from ( "
+ "(select o.id as officeId, l.group_id as groupId, l.client_id as clientId, null as savingsId, l.id as loanId, null as transactionId, null as entityId from m_loan l "
+ getClientOfficeJoinCondition("l") + " where l.id = ?)" + " union all "
+ "(select o.id as officeId, l.group_id as groupId, l.client_id as clientId, null as savingsId, l.id as loanId, null as transactionId, null as entityId from m_loan l "
+ getGroupOfficeJoinCondition("l") + " where l.id = ?)" + " ) as x";
}
case SAVINGS -> {
params.add(hierarchyPattern);
params.add(appTableId);
params.add(hierarchyPattern);
params.add(appTableId);
yield "select distinct x.* from ( "
+ "(select o.id as officeId, s.group_id as groupId, s.client_id as clientId, s.id as savingsId, null as loanId, null as transactionId, null as entityId "
+ "from m_savings_account s " + getClientOfficeJoinCondition("s") + " where s.id = ?)" + " union all "
+ "(select o.id as officeId, s.group_id as groupId, s.client_id as clientId, s.id as savingsId, null as loanId, null as transactionId, null as entityId "
+ "from m_savings_account s " + getGroupOfficeJoinCondition("s") + " where s.id = ?)" + " ) as x";
}
case SAVINGS_TRANSACTION -> {
params.add(hierarchyPattern);
params.add(appTableId);
params.add(hierarchyPattern);
params.add(appTableId);
yield "select distinct x.* from ( "
+ "(select o.id as officeId, s.group_id as groupId, s.client_id as clientId, s.id as savingsId, null as loanId, t.id as transactionId, null as entityId "
+ "from m_savings_account_transaction t join m_savings_account s on t.savings_account_id = s.id "
+ getClientOfficeJoinCondition("s") + " where t.id = ?)" + " union all "
+ "(select o.id as officeId, s.group_id as groupId, s.client_id as clientId, s.id as savingsId, null as loanId, t.id as transactionId, null as entityId "
+ "from m_savings_account_transaction t join m_savings_account s on t.savings_account_id = s.id "
+ getGroupOfficeJoinCondition("s") + " where t.id = ?)" + " ) as x";
}
case CLIENT -> {
params.add(hierarchyPattern);
params.add(appTableId);
yield "select o.id as officeId, null as groupId, c.id as clientId, null as savingsId, null as loanId, null as transactionId, null as entityId from m_client c "
+ getOfficeJoinCondition("c") + " where c.id = ?";
}
case GROUP, CENTER -> {
params.add(hierarchyPattern);
params.add(appTableId);
yield "select o.id as officeId, g.id as groupId, null as clientId, null as savingsId, null as loanId, null as transactionId, null as entityId from m_group g "
+ getOfficeJoinCondition("g") + " where g.id = ?";
}
case OFFICE -> {
params.add(hierarchyPattern);
params.add(appTableId);
yield "select o.id as officeId, null as groupId, null as clientId, null as savingsId, null as loanId, null as transactionId, null as entityId from m_office o "
+ "where o.hierarchy like ? and o.id = ?";
}
case LOAN_PRODUCT, SAVINGS_PRODUCT, SHARE_PRODUCT -> {
params.add(appTableId);
yield "select null as officeId, null as groupId, null as clientId, null as savingsId, null as loanId, null as transactionId, p.id as entityId from "
+ entityTable.getName() + " as p WHERE p.id = ?";
}
default -> throw new PlatformDataIntegrityException("error.msg.invalid.dataScopeCriteria",
"Application Table: " + entityTable.getName() + " not catered for in data Scoping");
};
}
public String getOfficeJoinCondition(String officeHierarchy, String joinTableAlias) {
return " join m_office o on o.id = " + joinTableAlias + ".office_id and o.hierarchy like '" + officeHierarchy + "%' ";
public String getOfficeJoinCondition(String joinTableAlias) {
return " join m_office o on o.id = " + joinTableAlias + ".office_id and o.hierarchy like ? ";
}
public String getGroupOfficeJoinCondition(String officeHierarchy, String appTableAlias) {
return " join m_group g on g.id = " + appTableAlias + ".group_id " + getOfficeJoinCondition(officeHierarchy, "g");
public String getGroupOfficeJoinCondition(String appTableAlias) {
return " join m_group g on g.id = " + appTableAlias + ".group_id " + getOfficeJoinCondition("g");
}
public String getClientOfficeJoinCondition(String officeHierarchy, String appTableAlias) {
return " join m_client c on c.id = " + appTableAlias + ".client_id " + getOfficeJoinCondition(officeHierarchy, "c");
public String getClientOfficeJoinCondition(String appTableAlias) {
return " join m_client c on c.id = " + appTableAlias + ".client_id " + getOfficeJoinCondition("c");
}
public GenericResultsetData retrieveDataTableGenericResultSet(final EntityTables entityTable, final String dataTableName,
@@ -201,20 +231,21 @@ public class DatatableUtil {
final List<ResultsetColumnHeaderData> columnHeaders = genericDataService.fillResultsetColumnHeaders(dataTableName);
final boolean multiRow = isMultirowDatatable(columnHeaders);
String whereClause = getFKField(entityTable) + " = " + appTableId;
sqlValidator.validate(whereClause);
String sql = "select * from " + sqlGenerator.escape(dataTableName) + " where " + whereClause;
final List<Object> params = new ArrayList<>();
params.add(appTableId);
String sql = "select * from " + sqlGenerator.escape(dataTableName) + " where " + getFKField(entityTable) + " = ?";
// id only used for reading a specific entry that belongs to appTableId (in a one to many datatable)
if (multiRow && id != null) {
sql = sql + " and " + TABLE_FIELD_ID + " = " + id;
sql = sql + " and " + TABLE_FIELD_ID + " = ?";
params.add(id);
}
if (StringUtils.isNotBlank(order)) {
columnValidator.validateSqlInjection(sql, order);
sql = sql + " order by " + order;
}
final List<ResultsetRowData> result = genericDataService.fillResultsetRowData(sql, columnHeaders);
final List<ResultsetRowData> result = genericDataService.fillResultsetRowData(sql, columnHeaders, params.toArray());
return new GenericResultsetData(columnHeaders, result);
}
@@ -152,9 +152,7 @@ public class DatatableWriteServiceImpl implements DatatableWriteService {
@Override
public void registerDatatable(final String dataTableName, final String entityName, final String entitySubType) {
Integer category = DataTableApiConstant.CATEGORY_DEFAULT;
final String permissionSql = this.getPermissionSql(dataTableName);
this.registerDataTable(entityName, dataTableName, entitySubType, category, permissionSql);
this.registerDataTable(entityName, dataTableName, entitySubType, category);
}
@Transactional
@@ -167,13 +165,13 @@ public class DatatableWriteServiceImpl implements DatatableWriteService {
Integer category = this.getCategory(command);
this.dataTableValidator.validateDataTableRegistration(command.json());
final String permissionSql = this.getPermissionSql(dataTableName);
this.registerDataTable(applicationTableName, dataTableName, entitySubType, category, permissionSql);
this.registerDataTable(applicationTableName, dataTableName, entitySubType, category);
}
@Transactional
@Override
public void registerDatatable(final JsonCommand command, final String permissionSql) {
// permissionSql parameter is kept for interface compatibility but ignored in favor of parameterized queries
final String applicationTableName = datatableReadService.getTableName(command.getUrl());
final String dataTableName = datatableReadService.getDataTableName(command.getUrl());
final String entitySubType = command.stringValueOfParameterNamed(ENTITY_SUB_TYPE);
@@ -182,30 +180,23 @@ public class DatatableWriteServiceImpl implements DatatableWriteService {
this.dataTableValidator.validateDataTableRegistration(command.json());
this.registerDataTable(applicationTableName, dataTableName, entitySubType, category, permissionSql);
this.registerDataTable(applicationTableName, dataTableName, entitySubType, category);
}
@Transactional
@Override
public void deregisterDatatable(final String datatable) {
datatableUtil.validateDatatableRegistered(datatable);
final String permissionList = "('CREATE_" + datatable + "', 'CREATE_" + datatable + "_CHECKER', 'READ_" + datatable + "', 'UPDATE_"
+ datatable + "', 'UPDATE_" + datatable + "_CHECKER', 'DELETE_" + datatable + "', 'DELETE_" + datatable + "_CHECKER')";
final Object[] permissionCodes = { "CREATE_" + datatable, "CREATE_" + datatable + "_CHECKER", "READ_" + datatable,
"UPDATE_" + datatable, "UPDATE_" + datatable + "_CHECKER", "DELETE_" + datatable, "DELETE_" + datatable + "_CHECKER" };
final String placeholders = "(?, ?, ?, ?, ?, ?, ?)";
final String deleteRolePermissionsSql = "delete from m_role_permission where m_role_permission.permission_id in (select id from m_permission where code in "
+ permissionList + ")";
final String deletePermissionsSql = "delete from m_permission where code in " + permissionList;
final String deleteRegisteredDatatableSql = "delete from x_registered_table where registered_table_name = '" + datatable + "'";
final String deleteFromConfigurationSql = "delete from c_configuration where name ='" + datatable + "'";
String[] sqlArray = new String[4];
sqlArray[0] = deleteRolePermissionsSql;
sqlArray[1] = deletePermissionsSql;
sqlArray[2] = deleteRegisteredDatatableSql;
sqlArray[3] = deleteFromConfigurationSql;
this.jdbcTemplate.batchUpdate(sqlArray); // NOSONAR
this.jdbcTemplate
.update("DELETE FROM m_role_permission WHERE m_role_permission.permission_id IN (SELECT id FROM m_permission WHERE code IN "
+ placeholders + ")", permissionCodes);
this.jdbcTemplate.update("DELETE FROM m_permission WHERE code IN " + placeholders, permissionCodes);
this.jdbcTemplate.update("DELETE FROM x_registered_table WHERE registered_table_name = ?", datatable);
this.jdbcTemplate.update("DELETE FROM c_configuration WHERE name = ?", datatable);
}
@Transactional
@@ -605,8 +596,8 @@ public class DatatableWriteServiceImpl implements DatatableWriteService {
return deleteDatatableEntries(dataTableName, appTableId, datatableId, command);
}
private void registerDataTable(final String entityName, final String dataTableName, final String entitySubType, final Integer category,
final String permissionsSql) {
private void registerDataTable(final String entityName, final String dataTableName, final String entitySubType,
final Integer category) {
datatableUtil.resolveEntity(entityName);
datatableUtil.validateDatatableName(dataTableName);
validateDataTableExists(dataTableName);
@@ -622,7 +613,7 @@ public class DatatableWriteServiceImpl implements DatatableWriteService {
try {
this.namedParameterJdbcTemplate.update(registerDatatableSql, paramMap);
this.jdbcTemplate.update(permissionsSql);
this.registerPermissions(dataTableName, true);
// add the registered table to the config if it is a ppi
if (category.equals(DataTableApiConstant.CATEGORY_PPI)) {
@@ -638,24 +629,21 @@ public class DatatableWriteServiceImpl implements DatatableWriteService {
}
}
private String getPermissionSql(final String dataTableName) {
final String createPermission = "'CREATE_" + dataTableName + "'";
final String createPermissionChecker = "'CREATE_" + dataTableName + "_CHECKER'";
final String readPermission = "'READ_" + dataTableName + "'";
final String updatePermission = "'UPDATE_" + dataTableName + "'";
final String updatePermissionChecker = "'UPDATE_" + dataTableName + "_CHECKER'";
final String deletePermission = "'DELETE_" + dataTableName + "'";
final String deletePermissionChecker = "'DELETE_" + dataTableName + "_CHECKER'";
private void registerPermissions(final String dataTableName, final boolean canMakerChecker) {
final List<String> escapedColumns = Stream.of("grouping", "code", "action_name", "entity_name", "can_maker_checker")
.map(sqlGenerator::escape).toList();
final String columns = String.join(", ", escapedColumns);
return "insert into m_permission (" + columns + ") values " + "('datatable', " + createPermission + ", 'CREATE', '" + dataTableName
+ "', true)," + "('datatable', " + createPermissionChecker + ", 'CREATE', '" + dataTableName + "', false),"
+ "('datatable', " + readPermission + ", 'READ', '" + dataTableName + "', false)," + "('datatable', " + updatePermission
+ ", 'UPDATE', '" + dataTableName + "', true)," + "('datatable', " + updatePermissionChecker + ", 'UPDATE', '"
+ dataTableName + "', false)," + "('datatable', " + deletePermission + ", 'DELETE', '" + dataTableName + "', true),"
+ "('datatable', " + deletePermissionChecker + ", 'DELETE', '" + dataTableName + "', false)";
final String sql = "INSERT INTO m_permission (" + columns + ") VALUES ('datatable', ?, ?, ?, ?)";
final Object[][] permissions = { { "CREATE_" + dataTableName, "CREATE", dataTableName, canMakerChecker },
{ "CREATE_" + dataTableName + "_CHECKER", "CREATE", dataTableName, false },
{ "READ_" + dataTableName, "READ", dataTableName, false },
{ "UPDATE_" + dataTableName, "UPDATE", dataTableName, canMakerChecker },
{ "UPDATE_" + dataTableName + "_CHECKER", "UPDATE", dataTableName, false },
{ "DELETE_" + dataTableName, "DELETE", dataTableName, canMakerChecker },
{ "DELETE_" + dataTableName + "_CHECKER", "DELETE", dataTableName, false }, };
for (Object[] params : permissions) {
this.jdbcTemplate.update(sql, params);
}
}
private Integer getCategory(final JsonCommand command) {
@@ -897,9 +885,9 @@ public class DatatableWriteServiceImpl implements DatatableWriteService {
String schemaSql = databaseTypeResolver.isMySQL() ? "i.TABLE_SCHEMA = SCHEMA()"
: "i.table_catalog = current_catalog AND i.table_schema = current_schema";
String findFKSql = "SELECT count(*) FROM information_schema.TABLE_CONSTRAINTS i" + " WHERE i.CONSTRAINT_TYPE = 'FOREIGN KEY' AND "
+ schemaSql + " AND i.TABLE_NAME = '" + datatableName + "' AND i.CONSTRAINT_NAME = '" + fkName + "' ";
+ schemaSql + " AND i.TABLE_NAME = ? AND i.CONSTRAINT_NAME = ?";
final Integer count = this.jdbcTemplate.queryForObject(findFKSql, Integer.class); // NOSONAR
final Integer count = this.jdbcTemplate.queryForObject(findFKSql, Integer.class, datatableName, fkName); // NOSONAR
if (count != null && count > 0) {
codeMappings.add(datatableAlias + "_" + name);
constrainBuilder.append(", DROP FOREIGN KEY ").append(sqlGenerator.escape(fkName)).append(" ");
@@ -908,36 +896,28 @@ public class DatatableWriteServiceImpl implements DatatableWriteService {
private void registerColumnCodeMapping(final Map<String, Long> codeMappings) {
if (codeMappings != null && !codeMappings.isEmpty()) {
final String[] addSqlList = new String[codeMappings.size()];
int i = 0;
final String sql = "INSERT INTO x_table_column_code_mappings (column_alias_name, code_id) VALUES (?, ?)";
for (final Map.Entry<String, Long> mapEntry : codeMappings.entrySet()) {
addSqlList[i++] = "insert into x_table_column_code_mappings (column_alias_name, code_id) values ('" + mapEntry.getKey()
+ "'," + mapEntry.getValue() + ");";
this.jdbcTemplate.update(sql, mapEntry.getKey(), mapEntry.getValue());
}
this.jdbcTemplate.batchUpdate(addSqlList);
}
}
private void deleteColumnCodeMapping(final List<String> columnNames) {
if (columnNames != null && !columnNames.isEmpty()) {
final String[] deleteSqlList = new String[columnNames.size()];
int i = 0;
final String sql = "DELETE FROM x_table_column_code_mappings WHERE column_alias_name = ?";
for (final String columnName : columnNames) {
deleteSqlList[i++] = "DELETE FROM x_table_column_code_mappings WHERE column_alias_name='" + columnName + "';";
this.jdbcTemplate.update(sql, columnName);
}
this.jdbcTemplate.batchUpdate(deleteSqlList);
}
}
private int getCodeIdForColumn(final String dataTableNameAlias, final String name) {
final StringBuilder checkColumnCodeMapping = new StringBuilder();
checkColumnCodeMapping.append("select ccm.code_id from x_table_column_code_mappings ccm where ccm.column_alias_name='")
.append(dataTableNameAlias).append("_").append(name).append("'");
final String sql = "SELECT ccm.code_id FROM x_table_column_code_mappings ccm WHERE ccm.column_alias_name = ?";
final String columnAliasName = dataTableNameAlias + "_" + name;
Integer codeId = 0;
try {
codeId = this.jdbcTemplate.queryForObject(checkColumnCodeMapping.toString(), Integer.class);
codeId = this.jdbcTemplate.queryForObject(sql, Integer.class, columnAliasName);
} catch (final EmptyResultDataAccessException e) {
log.warn("Error occurred.", e);
}
@@ -1327,10 +1307,9 @@ public class DatatableWriteServiceImpl implements DatatableWriteService {
whereColumn = TABLE_FIELD_ID;
whereValue = datatableId;
}
String sql = "DELETE FROM " + sqlGenerator.escape(dataTableName) + " WHERE " + sqlGenerator.escape(whereColumn) + " = "
+ whereValue;
String sql = "DELETE FROM " + sqlGenerator.escape(dataTableName) + " WHERE " + sqlGenerator.escape(whereColumn) + " = ?";
this.jdbcTemplate.update(sql); // NOSONAR
this.jdbcTemplate.update(sql, whereValue); // NOSONAR
final Map<String, Object> dataParams = null;
final DatatableEntryDetails datatableEntryDetails = new DatatableEntryDetails(dataTableName, entityTable, datatableId, appTableId,
dataParams);
@@ -1351,8 +1330,8 @@ public class DatatableWriteServiceImpl implements DatatableWriteService {
private boolean isDatatableAttachedToEntityDatatableCheck(final String datatableName) {
String sql = "SELECT COUNT(edc.x_registered_table_name) FROM x_registered_table xrt"
+ " JOIN m_entity_datatable_check edc ON edc.x_registered_table_name = xrt.registered_table_name"
+ " WHERE edc.x_registered_table_name = '" + datatableName + "'";
final Long count = this.jdbcTemplate.queryForObject(sql, Long.class); // NOSONAR
+ " WHERE edc.x_registered_table_name = ?";
final Long count = this.jdbcTemplate.queryForObject(sql, Long.class, datatableName); // NOSONAR
return count != null && count > 0;
}
@@ -141,6 +141,13 @@ public class GenericDataServiceImpl implements GenericDataService {
return fillResultsetRowData(rs, columnHeaders);
}
@NonNull
@Override
public List<ResultsetRowData> fillResultsetRowData(final String sql, List<ResultsetColumnHeaderData> columnHeaders, Object... args) {
final SqlRowSet rs = jdbcTemplate.queryForRowSet(sql, args);
return fillResultsetRowData(rs, columnHeaders);
}
@NonNull
private static List<ResultsetRowData> fillResultsetRowData(SqlRowSet rs, List<ResultsetColumnHeaderData> columnHeaders) {
final SqlRowSetMetaData rsmd = rs.getMetaData();
@@ -18,13 +18,9 @@
*/
package org.apache.fineract.infrastructure.survey.service;
import java.util.List;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.infrastructure.dataqueries.service.DatatableReadService;
import org.apache.fineract.infrastructure.dataqueries.service.DatatableWriteService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -36,39 +32,14 @@ import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
public class WriteSurveyServiceImpl implements WriteSurveyService {
private final DatatableReadService datatableReadService;
private final DatatableWriteService datatableWriteService;
private final DatabaseSpecificSQLGenerator sqlGenerator;
@Override
@Transactional
public CommandProcessingResult registerSurvey(JsonCommand command) {
final String dataTableName = datatableReadService.getDataTableName(command.getUrl());
final String permissionSql = this.getPermissionSql(dataTableName);
datatableWriteService.registerDatatable(command, permissionSql);
datatableWriteService.registerDatatable(command);
return CommandProcessingResult.commandOnlyResult(command.commandId());
}
private String getPermissionSql(final String dataTableName) {
final String createPermission = "'CREATE_" + dataTableName + "'";
final String createPermissionChecker = "'CREATE_" + dataTableName + "_CHECKER'";
final String readPermission = "'READ_" + dataTableName + "'";
final String updatePermission = "'UPDATE_" + dataTableName + "'";
final String updatePermissionChecker = "'UPDATE_" + dataTableName + "_CHECKER'";
final String deletePermission = "'DELETE_" + dataTableName + "'";
final String deletePermissionChecker = "'DELETE_" + dataTableName + "_CHECKER'";
final List<String> escapedColumns = Stream.of("grouping", "code", "action_name", "entity_name", "can_maker_checker")
.map(sqlGenerator::escape).toList();
final String columns = String.join(", ", escapedColumns);
return "insert into m_permission (" + columns + ") values " + "('datatable', " + createPermission + ", 'CREATE', '" + dataTableName
+ "', false)," + "('datatable', " + createPermissionChecker + ", 'CREATE', '" + dataTableName + "', false),"
+ "('datatable', " + readPermission + ", 'READ', '" + dataTableName + "', false)," + "('datatable', " + updatePermission
+ ", 'UPDATE', '" + dataTableName + "', false)," + "('datatable', " + updatePermissionChecker + ", 'UPDATE', '"
+ dataTableName + "', false)," + "('datatable', " + deletePermission + ", 'DELETE', '" + dataTableName + "', false),"
+ "('datatable', " + deletePermissionChecker + ", 'DELETE', '" + dataTableName + "', false)";
}
@Transactional
@@ -134,6 +134,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
final PaginationParameters paginationParameters) {
this.context.authenticatedUser();
this.paginationParametersDataValidator.validateParameterValues(paginationParameters, supportedOrderByValues,
depositAccountType.resourceName());
final DepositAccountMapper depositAccountMapper = this.getDepositAccountMapper(depositAccountType);
if (depositAccountMapper == null) {
return null;
@@ -0,0 +1,91 @@
/**
* 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.dataqueries.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.infrastructure.dataqueries.data.DataTableValidator;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.infrastructure.security.service.SqlValidator;
import org.apache.fineract.portfolio.search.service.SearchUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
@ExtendWith(MockitoExtension.class)
class DatatableReadServiceImplCountTest {
private static final Long APP_TABLE_ID = 42L;
private static final String DATATABLE_NAME = "dt_loan_test";
private static final String FK_COLUMN = "loan_id";
@Mock
private JdbcTemplate jdbcTemplate;
@Mock
private DatabaseSpecificSQLGenerator sqlGenerator;
@Mock
private PlatformSecurityContext context;
@Mock
private GenericDataService genericDataService;
@Mock
private DataTableValidator dataTableValidator;
@Mock
private SqlValidator sqlValidator;
@Mock
private SearchUtil searchUtil;
@Mock
private DatatableUtil datatableUtil;
private DatatableReadServiceImpl underTest;
@BeforeEach
void setUp() {
underTest = new DatatableReadServiceImpl(jdbcTemplate, sqlGenerator, context, genericDataService, dataTableValidator, sqlValidator,
searchUtil, datatableUtil);
}
@Test
void testCountDatatableEntriesUsesParameterizedQuery() {
when(sqlGenerator.escape(anyString())).thenAnswer(invocation -> "\"" + invocation.getArgument(0) + "\"");
when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), eq(APP_TABLE_ID))).thenReturn(3L);
Long count = underTest.countDatatableEntries(DATATABLE_NAME, APP_TABLE_ID, FK_COLUMN);
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
verify(jdbcTemplate).queryForObject(sqlCaptor.capture(), eq(Long.class), eq(APP_TABLE_ID));
String sql = sqlCaptor.getValue();
assertTrue(sql.contains("= ?"), "SQL should use ? placeholder for appTableId");
assertFalse(sql.contains("= " + APP_TABLE_ID), "SQL should not contain concatenated appTableId");
assertTrue(sql.contains("\"" + FK_COLUMN + "\""), "SQL should contain escaped FK column name");
assertTrue(sql.contains("\"" + DATATABLE_NAME + "\""), "SQL should contain escaped datatable name");
assertEquals(3L, count, "Should return the count from the query");
}
}
@@ -0,0 +1,235 @@
/**
* 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.dataqueries.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.ArrayList;
import java.util.List;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.infrastructure.core.service.database.DatabaseType;
import org.apache.fineract.infrastructure.dataqueries.data.EntityTables;
import org.apache.fineract.infrastructure.dataqueries.data.ResultsetColumnHeaderData;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.infrastructure.security.service.SqlValidator;
import org.apache.fineract.infrastructure.security.utils.ColumnValidator;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.search.service.SearchUtil;
import org.apache.fineract.useradministration.domain.AppUser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.rowset.SqlRowSet;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class DatatableUtilTest {
private static final Long APP_TABLE_ID = 42L;
private static final String OFFICE_HIERARCHY = ".1.2.";
@Mock
private SearchUtil searchUtil;
@Mock
private JdbcTemplate jdbcTemplate;
@Mock
private SqlValidator sqlValidator;
@Mock
private PlatformSecurityContext context;
@Mock
private GenericDataService genericDataService;
@Mock
private DatabaseSpecificSQLGenerator sqlGenerator;
@Mock
private ColumnValidator columnValidator;
private DatatableUtil underTest;
@BeforeEach
void setUp() {
underTest = new DatatableUtil(searchUtil, jdbcTemplate, sqlValidator, context, genericDataService, sqlGenerator, columnValidator);
setupSecurityContext();
}
private void setupSecurityContext() {
AppUser currentUser = Mockito.mock(AppUser.class);
Office office = Mockito.mock(Office.class);
when(context.authenticatedUser()).thenReturn(currentUser);
when(currentUser.getOffice()).thenReturn(office);
when(office.getHierarchy()).thenReturn(OFFICE_HIERARCHY);
}
@Test
void testDataScopedSqlLoanUsesParameterizedQuery() {
List<Object> params = new ArrayList<>();
String sql = underTest.dataScopedSQL(EntityTables.LOAN, APP_TABLE_ID, params);
assertEquals(4, params.size(), "LOAN case should have 4 parameters");
assertTrue(sql.contains("where l.id = ?"), "SQL should use ? placeholder for loan id");
assertFalse(sql.contains(APP_TABLE_ID.toString()), "SQL should not contain concatenated appTableId");
assertFalse(sql.contains("'" + OFFICE_HIERARCHY), "SQL should not contain concatenated officeHierarchy");
assertEquals(OFFICE_HIERARCHY + "%", params.get(0), "First param should be hierarchy pattern");
assertEquals(APP_TABLE_ID, params.get(1), "Second param should be appTableId");
assertEquals(OFFICE_HIERARCHY + "%", params.get(2), "Third param should be hierarchy pattern");
assertEquals(APP_TABLE_ID, params.get(3), "Fourth param should be appTableId");
}
@Test
void testDataScopedSqlSavingsUsesParameterizedQuery() {
List<Object> params = new ArrayList<>();
String sql = underTest.dataScopedSQL(EntityTables.SAVINGS, APP_TABLE_ID, params);
assertEquals(4, params.size(), "SAVINGS case should have 4 parameters");
assertTrue(sql.contains("where s.id = ?"), "SQL should use ? placeholder for savings id");
assertFalse(sql.contains(APP_TABLE_ID.toString()), "SQL should not contain concatenated appTableId");
}
@Test
void testDataScopedSqlSavingsTransactionUsesParameterizedQuery() {
List<Object> params = new ArrayList<>();
String sql = underTest.dataScopedSQL(EntityTables.SAVINGS_TRANSACTION, APP_TABLE_ID, params);
assertEquals(4, params.size(), "SAVINGS_TRANSACTION case should have 4 parameters");
assertTrue(sql.contains("where t.id = ?"), "SQL should use ? placeholder for transaction id");
assertFalse(sql.contains(APP_TABLE_ID.toString()), "SQL should not contain concatenated appTableId");
}
@Test
void testDataScopedSqlClientUsesParameterizedQuery() {
List<Object> params = new ArrayList<>();
String sql = underTest.dataScopedSQL(EntityTables.CLIENT, APP_TABLE_ID, params);
assertEquals(2, params.size(), "CLIENT case should have 2 parameters");
assertTrue(sql.contains("where c.id = ?"), "SQL should use ? placeholder for client id");
assertFalse(sql.contains(APP_TABLE_ID.toString()), "SQL should not contain concatenated appTableId");
assertEquals(OFFICE_HIERARCHY + "%", params.get(0), "First param should be hierarchy pattern");
assertEquals(APP_TABLE_ID, params.get(1), "Second param should be appTableId");
}
@Test
void testDataScopedSqlGroupUsesParameterizedQuery() {
List<Object> params = new ArrayList<>();
String sql = underTest.dataScopedSQL(EntityTables.GROUP, APP_TABLE_ID, params);
assertEquals(2, params.size(), "GROUP case should have 2 parameters");
assertTrue(sql.contains("where g.id = ?"), "SQL should use ? placeholder for group id");
assertFalse(sql.contains(APP_TABLE_ID.toString()), "SQL should not contain concatenated appTableId");
}
@Test
void testDataScopedSqlOfficeUsesParameterizedQuery() {
List<Object> params = new ArrayList<>();
String sql = underTest.dataScopedSQL(EntityTables.OFFICE, APP_TABLE_ID, params);
assertEquals(2, params.size(), "OFFICE case should have 2 parameters");
assertTrue(sql.contains("o.hierarchy like ?"), "SQL should use ? placeholder for hierarchy LIKE");
assertTrue(sql.contains("o.id = ?"), "SQL should use ? placeholder for office id");
assertFalse(sql.contains("'" + OFFICE_HIERARCHY), "SQL should not contain concatenated officeHierarchy");
assertFalse(sql.contains(APP_TABLE_ID.toString()), "SQL should not contain concatenated appTableId");
}
@Test
void testDataScopedSqlProductUsesParameterizedQuery() {
List<Object> params = new ArrayList<>();
String sql = underTest.dataScopedSQL(EntityTables.LOAN_PRODUCT, APP_TABLE_ID, params);
assertEquals(1, params.size(), "PRODUCT case should have 1 parameter");
assertTrue(sql.contains("p.id = ?"), "SQL should use ? placeholder for product id");
assertFalse(sql.contains(APP_TABLE_ID.toString()), "SQL should not contain concatenated appTableId");
assertEquals(APP_TABLE_ID, params.get(0), "First param should be appTableId");
}
@Test
void testCheckMainResourceExistsWithinScopePassesParamsToQuery() {
SqlRowSet rs = Mockito.mock(SqlRowSet.class);
when(rs.next()).thenReturn(true).thenReturn(false);
when(rs.getObject(anyString())).thenReturn(1L);
when(jdbcTemplate.queryForRowSet(anyString(), any(Object[].class))).thenReturn(rs);
underTest.checkMainResourceExistsWithinScope(EntityTables.CLIENT, APP_TABLE_ID);
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<Object[]> paramsCaptor = ArgumentCaptor.forClass(Object[].class);
verify(jdbcTemplate).queryForRowSet(sqlCaptor.capture(), paramsCaptor.capture());
String sql = sqlCaptor.getValue();
Object[] capturedParams = paramsCaptor.getValue();
assertTrue(sql.contains("?"), "SQL should contain ? placeholders");
assertFalse(sql.contains(APP_TABLE_ID.toString()), "SQL should not contain concatenated appTableId");
assertTrue(capturedParams.length > 0, "Parameters array should not be empty");
}
@Test
void testRetrieveDataTableGenericResultSetUsesParameterizedQuery() {
EntityTables entityTable = EntityTables.LOAN;
String dataTableName = "test_datatable";
Long id = 5L;
when(sqlGenerator.escape(anyString())).thenReturn("\"" + dataTableName + "\"");
when(genericDataService.fillResultsetColumnHeaders(anyString())).thenReturn(createMultiRowHeaders());
when(searchUtil.findFiltered(any(), any())).thenReturn(ResultsetColumnHeaderData.basic("id", "bigint", DatabaseType.MYSQL));
when(genericDataService.fillResultsetRowData(anyString(), any(), any(Object[].class))).thenReturn(new ArrayList<>());
underTest.retrieveDataTableGenericResultSet(entityTable, dataTableName, APP_TABLE_ID, null, id);
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<Object[]> paramsCaptor = ArgumentCaptor.forClass(Object[].class);
verify(genericDataService).fillResultsetRowData(sqlCaptor.capture(), any(), paramsCaptor.capture());
String sql = sqlCaptor.getValue();
Object[] capturedParams = paramsCaptor.getValue();
assertTrue(sql.contains("= ?"), "SQL should use ? placeholder for foreign key");
assertTrue(sql.contains("id = ?"), "SQL should use ? placeholder for id");
assertFalse(sql.contains("= " + APP_TABLE_ID), "SQL should not contain concatenated appTableId");
assertFalse(sql.contains("= " + id), "SQL should not contain concatenated id");
assertEquals(2, capturedParams.length, "Should have 2 parameters (appTableId + id)");
assertEquals(APP_TABLE_ID, capturedParams[0], "First param should be appTableId");
assertEquals(id, capturedParams[1], "Second param should be id");
}
@Test
void testOfficeJoinConditionUsesPlaceholder() {
String joinCondition = underTest.getOfficeJoinCondition("t");
assertTrue(joinCondition.contains("o.hierarchy like ?"), "Join condition should use ? for hierarchy");
assertFalse(joinCondition.contains("like '"), "Join condition should not contain literal quote for hierarchy");
}
private List<ResultsetColumnHeaderData> createMultiRowHeaders() {
List<ResultsetColumnHeaderData> headers = new ArrayList<>();
headers.add(ResultsetColumnHeaderData.basic("id", "bigint", DatabaseType.MYSQL));
headers.add(ResultsetColumnHeaderData.basic("loan_id", "bigint", DatabaseType.MYSQL));
return headers;
}
}
@@ -0,0 +1,248 @@
/**
* 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.dataqueries.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.google.gson.JsonObject;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.fineract.infrastructure.codes.service.CodeReadPlatformService;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.serialization.DatatableCommandFromApiJsonDeserializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.infrastructure.core.service.database.DatabaseTypeResolver;
import org.apache.fineract.infrastructure.dataqueries.data.DataTableValidator;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.search.service.SearchUtil;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
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.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class DatatableWriteServiceImplTest {
private static final String DATATABLE_NAME = "dt_test_loan";
@Mock
private JdbcTemplate jdbcTemplate;
@Mock
private DatabaseTypeResolver databaseTypeResolver;
@Mock
private DatabaseSpecificSQLGenerator sqlGenerator;
@Mock
private PlatformSecurityContext context;
@Mock
private FromJsonHelper fromJsonHelper;
@Mock
private GenericDataService genericDataService;
@Mock
private DatatableCommandFromApiJsonDeserializer fromApiJsonDeserializer;
@Mock
private ConfigurationDomainService configurationDomainService;
@Mock
private CodeReadPlatformService codeReadPlatformService;
@Mock
private DataTableValidator dataTableValidator;
@Mock
private NamedParameterJdbcTemplate namedParameterJdbcTemplate;
@Mock
private DatatableKeywordGenerator datatableKeywordGenerator;
@Mock
private SearchUtil searchUtil;
@Mock
private BusinessEventNotifierService businessEventNotifierService;
@Mock
private DatatableReadService datatableReadService;
@Mock
private DatatableUtil datatableUtil;
@InjectMocks
private DatatableWriteServiceImpl underTest;
@Test
void testDeregisterDatatableUsesParameterizedQueries() {
underTest.deregisterDatatable(DATATABLE_NAME);
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<Object[]> paramsCaptor = ArgumentCaptor.forClass(Object[].class);
verify(jdbcTemplate, atLeastOnce()).update(sqlCaptor.capture(), paramsCaptor.capture());
List<String> allSql = sqlCaptor.getAllValues();
List<Object[]> allParams = paramsCaptor.getAllValues();
// First call: DELETE FROM m_role_permission WHERE ... IN (?, ?, ?, ?, ?, ?, ?)
assertTrue(allSql.get(0).contains("m_role_permission"), "First DELETE should target m_role_permission");
assertTrue(allSql.get(0).contains("?"), "SQL should use ? placeholders");
assertFalse(allSql.get(0).contains("'" + DATATABLE_NAME), "SQL should not contain concatenated datatable name");
assertEquals(7, allParams.get(0).length, "Should have 7 permission code parameters");
// Second call: DELETE FROM m_permission WHERE code IN (?, ?, ?, ?, ?, ?, ?)
assertTrue(allSql.get(1).contains("m_permission"), "Second DELETE should target m_permission");
assertTrue(allSql.get(1).contains("?"), "SQL should use ? placeholders");
assertEquals(7, allParams.get(1).length, "Should have 7 permission code parameters");
// Third call: DELETE FROM x_registered_table WHERE registered_table_name = ?
assertTrue(allSql.get(2).contains("x_registered_table"), "Third DELETE should target x_registered_table");
assertTrue(allSql.get(2).contains("= ?"), "SQL should use ? placeholder");
// Fourth call: DELETE FROM c_configuration WHERE name = ?
assertTrue(allSql.get(3).contains("c_configuration"), "Fourth DELETE should target c_configuration");
assertTrue(allSql.get(3).contains("= ?"), "SQL should use ? placeholder");
}
@Test
void testDeleteColumnCodeMappingUsesParameterizedQuery() {
List<String> columnNames = new ArrayList<>();
columnNames.add("test_column_1");
columnNames.add("test_column_2");
ReflectionTestUtils.invokeMethod(underTest, "deleteColumnCodeMapping", columnNames);
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
verify(jdbcTemplate, atLeastOnce()).update(sqlCaptor.capture(), anyString());
String sql = sqlCaptor.getValue();
assertTrue(sql.contains("DELETE FROM x_table_column_code_mappings"), "Should delete from x_table_column_code_mappings");
assertTrue(sql.contains("column_alias_name = ?"), "Should use ? placeholder for column_alias_name");
assertFalse(sql.contains("test_column"), "SQL should not contain concatenated column name");
}
@Test
void testGetCodeIdForColumnUsesParameterizedQuery() {
String dataTableAlias = "dt_test";
String columnName = "status";
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), anyString())).thenReturn(42);
ReflectionTestUtils.invokeMethod(underTest, "getCodeIdForColumn", dataTableAlias, columnName);
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> paramCaptor = ArgumentCaptor.forClass(String.class);
verify(jdbcTemplate).queryForObject(sqlCaptor.capture(), eq(Integer.class), paramCaptor.capture());
String sql = sqlCaptor.getValue();
String param = paramCaptor.getValue();
assertTrue(sql.contains("column_alias_name = ?"), "SQL should use ? placeholder");
assertFalse(sql.contains(dataTableAlias + "_" + columnName), "SQL should not contain concatenated alias");
assertEquals(dataTableAlias + "_" + columnName, param, "Parameter should be the combined alias_name");
}
@Test
void testIsDatatableAttachedUsesParameterizedQuery() {
when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), anyString())).thenReturn(0L);
ReflectionTestUtils.invokeMethod(underTest, "isDatatableAttachedToEntityDatatableCheck", DATATABLE_NAME);
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> paramCaptor = ArgumentCaptor.forClass(String.class);
verify(jdbcTemplate).queryForObject(sqlCaptor.capture(), eq(Long.class), paramCaptor.capture());
String sql = sqlCaptor.getValue();
assertTrue(sql.contains("x_registered_table_name = ?"), "SQL should use ? placeholder for datatable name");
assertFalse(sql.contains("'" + DATATABLE_NAME + "'"), "SQL should not contain concatenated datatable name");
assertEquals(DATATABLE_NAME, paramCaptor.getValue(), "Parameter should be the datatable name");
}
@Test
void testParseDatatableColumnForDropUsesParameterizedFKLookup() {
when(databaseTypeResolver.isMySQL()).thenReturn(true);
when(sqlGenerator.escape(anyString())).thenAnswer(invocation -> "\"" + invocation.getArgument(0) + "\"");
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), anyString(), anyString())).thenReturn(0);
JsonObject column = new JsonObject();
column.addProperty("name", "test_col");
StringBuilder sqlBuilder = new StringBuilder();
StringBuilder constrainBuilder = new StringBuilder();
List<String> codeMappings = new ArrayList<>();
ReflectionTestUtils.invokeMethod(underTest, "parseDatatableColumnForDrop", column, sqlBuilder, DATATABLE_NAME, constrainBuilder,
codeMappings);
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> param1Captor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> param2Captor = ArgumentCaptor.forClass(String.class);
verify(jdbcTemplate).queryForObject(sqlCaptor.capture(), eq(Integer.class), param1Captor.capture(), param2Captor.capture());
String sql = sqlCaptor.getValue();
assertTrue(sql.contains("i.TABLE_NAME = ?"), "SQL should use ? placeholder for TABLE_NAME");
assertTrue(sql.contains("i.CONSTRAINT_NAME = ?"), "SQL should use ? placeholder for CONSTRAINT_NAME");
assertFalse(sql.contains("'" + DATATABLE_NAME + "'"), "SQL should not contain concatenated table name");
assertEquals(DATATABLE_NAME, param1Captor.getValue(), "First param should be the table name");
assertTrue(param2Captor.getValue().startsWith("fk_"), "Second param should be the FK constraint name");
}
@Test
void testRegisterPermissionsUsesParameterizedQuery() {
when(sqlGenerator.escape(anyString())).thenAnswer(invocation -> "\"" + invocation.getArgument(0) + "\"");
ReflectionTestUtils.invokeMethod(underTest, "registerPermissions", DATATABLE_NAME, true);
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<Object[]> paramsCaptor = ArgumentCaptor.forClass(Object[].class);
verify(jdbcTemplate, atLeastOnce()).update(sqlCaptor.capture(), paramsCaptor.capture());
String sql = sqlCaptor.getValue();
assertTrue(sql.contains("INSERT INTO m_permission"), "SQL should be an INSERT into m_permission");
assertTrue(sql.contains("VALUES ('datatable', ?, ?, ?, ?)"), "SQL should use ? placeholders for values");
assertFalse(sql.contains("'" + DATATABLE_NAME + "'"), "SQL should not contain concatenated datatable name");
List<Object[]> allParams = paramsCaptor.getAllValues();
assertEquals(7, allParams.size(), "Should create 7 permission entries");
assertEquals("CREATE_" + DATATABLE_NAME, allParams.get(0)[0], "First permission code should be CREATE_<datatable>");
}
@Test
void testRegisterColumnCodeMappingUsesParameterizedQuery() {
Map<String, Long> codeMappings = new HashMap<>();
codeMappings.put("dt_test_status", 10L);
ReflectionTestUtils.invokeMethod(underTest, "registerColumnCodeMapping", codeMappings);
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
verify(jdbcTemplate).update(sqlCaptor.capture(), eq("dt_test_status"), eq(10L));
String sql = sqlCaptor.getValue();
assertTrue(sql.contains("INSERT INTO x_table_column_code_mappings"), "SQL should insert into code mappings table");
assertTrue(sql.contains("VALUES (?, ?)"), "SQL should use ? placeholders");
}
}