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
@@ -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");
}
}