diff --git a/api/src/main/resources/connection-api.yaml b/api/src/main/resources/connection-api.yaml index 626087094..ad70e6e14 100644 --- a/api/src/main/resources/connection-api.yaml +++ b/api/src/main/resources/connection-api.yaml @@ -289,6 +289,12 @@ paths: description: Optional project directory. If provided, the operation will be performed in the context of the project. schema: type: string + - name: allLevels + in: query + required: false + description: Optional flag to include connections from all supported levels for the request context. + schema: + type: boolean responses: '200': description: Successfully retrieved connections @@ -568,6 +574,10 @@ components: description: Partitioning information items: type: object + level: + type: string + description: Connection level for list results + example: "Repository" IDatabaseConnectionList: type: object description: List of database connections (maps to org.pentaho.ui.database.event.IDatabaseConnectionList) diff --git a/core/src/main/java/org/pentaho/platform/dataaccess/datasource/utils/DatabaseConnectionUtils.java b/core/src/main/java/org/pentaho/platform/dataaccess/datasource/utils/DatabaseConnectionUtils.java new file mode 100644 index 000000000..89e69c557 --- /dev/null +++ b/core/src/main/java/org/pentaho/platform/dataaccess/datasource/utils/DatabaseConnectionUtils.java @@ -0,0 +1,132 @@ +package org.pentaho.platform.dataaccess.datasource.utils; + +import org.pentaho.database.model.IDatabaseConnection; +import org.pentaho.database.model.PartitionDatabaseMeta; +import org.pentaho.di.core.variables.VariableSpace; + +import java.util.HashMap; +import java.util.Map; + +/** + * Utility class for applying variable substitution to database connection fields. + * Applies environmentSubstitute to connection fields that may contain variable references + * (in the form ${VARIABLE_NAME}). + */ +public class DatabaseConnectionUtils { + + /** + * Apply environment variable substitution to an IDatabaseConnection using the provided VariableSpace. + * Substitutes variables in the following fields: + * - name (connection identifier, may be used in pooling keys) + * - hostname + * - databaseName + * - databasePort + * - username + * - password + * - connectSql + * - sqlServerInstance + * - dataTablespace (Oracle and other database-specific tablespaces) + * - indexTablespace (Oracle and other database-specific tablespaces) + * - connectionPoolingProperties (all values) + * - partitioning information (if present) + * + * @param connection The database connection to update (modified in-place) + * @param variableSpace The variable space to use for substitution + */ + public static void applyEnvironmentSubstitution( final IDatabaseConnection connection, + final VariableSpace variableSpace ) { + if ( connection == null || variableSpace == null ) { + return; + } + + // Substitute connection name (used for pooling and identification) + if ( connection.getName() != null ) { + connection.setName( variableSpace.environmentSubstitute( connection.getName() ) ); + } + + // Substitute basic connection fields + if ( connection.getHostname() != null ) { + connection.setHostname( variableSpace.environmentSubstitute( connection.getHostname() ) ); + } + + if ( connection.getDatabaseName() != null ) { + connection.setDatabaseName( variableSpace.environmentSubstitute( connection.getDatabaseName() ) ); + } + + if ( connection.getDatabasePort() != null ) { + connection.setDatabasePort( variableSpace.environmentSubstitute( connection.getDatabasePort() ) ); + } + + if ( connection.getUsername() != null ) { + connection.setUsername( variableSpace.environmentSubstitute( connection.getUsername() ) ); + } + + if ( connection.getPassword() != null ) { + connection.setPassword( variableSpace.environmentSubstitute( connection.getPassword() ) ); + } + + // Substitute connect SQL + if ( connection.getConnectSql() != null ) { + connection.setConnectSql( variableSpace.environmentSubstitute( connection.getConnectSql() ) ); + } + + // Substitute SQL Server instance + if ( connection.getSQLServerInstance() != null ) { + connection.setSQLServerInstance( variableSpace.environmentSubstitute( connection.getSQLServerInstance() ) ); + } + + // Substitute tablespace fields (Oracle and similar databases) + if ( connection.getDataTablespace() != null ) { + connection.setDataTablespace( variableSpace.environmentSubstitute( connection.getDataTablespace() ) ); + } + + if ( connection.getIndexTablespace() != null ) { + connection.setIndexTablespace( variableSpace.environmentSubstitute( connection.getIndexTablespace() ) ); + } + + // Substitute connection pooling properties + if ( connection.getConnectionPoolingProperties() != null + && !connection.getConnectionPoolingProperties().isEmpty() ) { + Map substitutedPoolingProps = new HashMap<>(); + for ( Map.Entry entry : connection.getConnectionPoolingProperties().entrySet() ) { + String substitutedValue = entry.getValue() != null ? variableSpace.environmentSubstitute( entry.getValue() ) + : null; + substitutedPoolingProps.put( entry.getKey(), substitutedValue ); + } + connection.setConnectionPoolingProperties( substitutedPoolingProps ); + } + + // Substitute partitioning information if present + if ( connection.isPartitioned() && connection.getPartitioningInformation() != null ) { + for ( PartitionDatabaseMeta partition : connection.getPartitioningInformation() ) { + applyEnvironmentSubstitutionToPartition( partition, variableSpace ); + } + } + } + + /** + * Apply environment variable substitution to a PartitionDatabaseMeta. + * Substitutes variables in hostname, port, and database name fields. + * + * @param partition The partition metadata to update (modified in-place) + * @param variableSpace The variable space to use for substitution + */ + private static void applyEnvironmentSubstitutionToPartition( final PartitionDatabaseMeta partition, + final VariableSpace variableSpace ) { + if ( partition == null || variableSpace == null ) { + return; + } + + if ( partition.getHostname() != null ) { + partition.setHostname( variableSpace.environmentSubstitute( partition.getHostname() ) ); + } + + if ( partition.getPort() != null ) { + partition.setPort( variableSpace.environmentSubstitute( partition.getPort() ) ); + } + + if ( partition.getDatabaseName() != null ) { + partition.setDatabaseName( variableSpace.environmentSubstitute( partition.getDatabaseName() ) ); + } + } +} diff --git a/core/src/main/java/org/pentaho/platform/dataaccess/datasource/wizard/service/impl/ConnectionService.java b/core/src/main/java/org/pentaho/platform/dataaccess/datasource/wizard/service/impl/ConnectionService.java index eb7aa3c48..72167cdb2 100644 --- a/core/src/main/java/org/pentaho/platform/dataaccess/datasource/wizard/service/impl/ConnectionService.java +++ b/core/src/main/java/org/pentaho/platform/dataaccess/datasource/wizard/service/impl/ConnectionService.java @@ -22,8 +22,11 @@ import org.pentaho.database.service.DatabaseDialectService; import org.pentaho.database.util.DatabaseUtil; import org.pentaho.di.core.encryption.Encr; +import org.pentaho.di.core.variables.VariableSpace; +import org.pentaho.di.core.variables.Variables; import org.pentaho.platform.api.engine.IAuthorizationPolicy; import org.pentaho.platform.api.engine.PentahoAccessControlException; +import org.pentaho.platform.dataaccess.datasource.utils.DatabaseConnectionUtils; import org.pentaho.platform.dataaccess.datasource.wizard.service.ConnectionServiceException; import org.pentaho.platform.dataaccess.datasource.wizard.service.api.ConnectionsApi; import org.pentaho.platform.dataaccess.datasource.wizard.service.impl.utils.UtilHtmlSanitizer; @@ -61,6 +64,7 @@ public class ConnectionService implements ConnectionsApi { private static final Log logger = LogFactory.getLog( ConnectionService.class ); protected static final String MEDIA_TYPE_JSON = "application/json"; protected static final String MEDIA_TYPE_TEXT_PLAIN = "text/plain"; + protected static final String CONNECTION_LEVEL_REPOSITORY = "Repository"; private ConnectionServiceImpl connectionService; private DatabaseDialectService dialectService; @@ -283,7 +287,7 @@ public IDatabaseConnectionPoolParameterList getPoolingParameters() { @Override public String testConnection( IDatabaseConnection databaseConnection, String projectDir ) { try { - applySavedPassword( databaseConnection, projectDir ); + applySavedPassword( databaseConnection, projectDir, true ); boolean success = connectionService.testConnection( databaseConnection ); if ( success ) { return Messages.getString( "ConnectionServiceImpl.INFO_0001_CONNECTION_SUCCEED", databaseConnection @@ -319,7 +323,7 @@ public String testConnection( IDatabaseConnection databaseConnection ) { public void updateConnection( IDatabaseConnection databaseConnection, String projectDir ) { sanitizer.sanitizeConnectionParameters( databaseConnection ); try { - applySavedPassword( databaseConnection ); + applySavedPassword( databaseConnection, false ); connectionService.updateConnection( databaseConnection ); // explicitly return a 200 instead of 204 No Content throw new WebApplicationException( Response.ok().build() ); @@ -358,12 +362,22 @@ public void updateConnection( IDatabaseConnection databaseConnection ) { /** * If password is empty, that means connection sent from UI and user didn't change password. Since we cleaned password * during sending to UI, we need to use stored password. + * + * @param conn the connection to update + * @param projectDir Optional project directory (used by subclasses) + * @param resolveVariables whether to resolve variables in the connection */ - protected void applySavedPassword( IDatabaseConnection conn, String projectDir ) throws ConnectionServiceException { - applySavedPassword( conn ); + protected void applySavedPassword( IDatabaseConnection conn, String projectDir, boolean resolveVariables ) throws ConnectionServiceException { + applySavedPassword( conn, resolveVariables ); } - private void applySavedPassword( IDatabaseConnection conn ) throws ConnectionServiceException { + private void applySavedPassword( IDatabaseConnection conn, boolean resolveVariables ) throws ConnectionServiceException { + + if ( resolveVariables ) { + VariableSpace variables = Variables.getADefaultVariableSpace(); + DatabaseConnectionUtils.applyEnvironmentSubstitution( conn, variables ); + } + if ( StringUtils.isBlank( conn.getPassword() ) ) { IDatabaseConnection savedConn; if ( conn.getId() != null ) { @@ -519,10 +533,13 @@ private void validateAccess() throws PentahoAccessControlException { * */ @Override - public IDatabaseConnectionList getConnections( String projectDir ) { + public IDatabaseConnectionList getConnections( String projectDir, Boolean allLevels ) { try { IDatabaseConnectionList databaseConnections = new DefaultDatabaseConnectionList(); List conns = connectionService.getConnections( true ); + for ( IDatabaseConnection conn : conns ) { + setConnectionLevel( conn, CONNECTION_LEVEL_REPOSITORY ); + } databaseConnections.setDatabaseConnections( conns ); return databaseConnections; } catch ( ConnectionServiceException ex ) { @@ -530,8 +547,16 @@ public IDatabaseConnectionList getConnections( String projectDir ) { } } + public IDatabaseConnectionList getConnections( String projectDir ) { + return getConnections( projectDir, Boolean.FALSE ); + } + public IDatabaseConnectionList getConnections() { - return getConnections( null ); + return getConnections( null, Boolean.FALSE ); + } + + protected void setConnectionLevel( IDatabaseConnection connection, String level ) { + connection.setLevel( level ); } /** diff --git a/core/src/test/java/org/pentaho/platform/dataaccess/datasource/utils/DatabaseConnectionUtilsTest.java b/core/src/test/java/org/pentaho/platform/dataaccess/datasource/utils/DatabaseConnectionUtilsTest.java new file mode 100644 index 000000000..cee4d3979 --- /dev/null +++ b/core/src/test/java/org/pentaho/platform/dataaccess/datasource/utils/DatabaseConnectionUtilsTest.java @@ -0,0 +1,281 @@ +package org.pentaho.platform.dataaccess.datasource.utils; + +import org.pentaho.database.model.DatabaseConnection; +import org.pentaho.database.model.IDatabaseConnection; +import org.pentaho.database.model.PartitionDatabaseMeta; +import org.pentaho.di.core.variables.VariableSpace; +import org.pentaho.di.core.variables.Variables; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +/** + * Tests for DatabaseConnectionUtils + */ +public class DatabaseConnectionUtilsTest { + + private VariableSpace variableSpace; + private IDatabaseConnection connection; + + @Before + public void setUp() { + variableSpace = new Variables(); + variableSpace.setVariable( "DB_HOST", "prod-host.example.com" ); + variableSpace.setVariable( "DB_NAME", "production_db" ); + variableSpace.setVariable( "DB_PORT", "5432" ); + variableSpace.setVariable( "DB_USER", "prod_user" ); + variableSpace.setVariable( "DB_PASS", "secret123" ); + variableSpace.setVariable( "SQL_INIT", "SET SESSION max_connections = 10;" ); + variableSpace.setVariable( "POOL_SIZE", "20" ); + variableSpace.setVariable( "CONN_NAME", "prod_db_connection" ); + + connection = new DatabaseConnection(); + } + + @Test + public void testSubstituteConnectionName() { + connection.setName( "${CONN_NAME}" ); + + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, variableSpace ); + + assertEquals( "prod_db_connection", connection.getName() ); + } + + @Test + public void testSubstituteBasicConnectionFields() { + connection.setHostname( "${DB_HOST}" ); + connection.setDatabaseName( "${DB_NAME}" ); + connection.setDatabasePort( "${DB_PORT}" ); + connection.setUsername( "${DB_USER}" ); + connection.setPassword( "${DB_PASS}" ); + + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, variableSpace ); + + assertEquals( "prod-host.example.com", connection.getHostname() ); + assertEquals( "production_db", connection.getDatabaseName() ); + assertEquals( "5432", connection.getDatabasePort() ); + assertEquals( "prod_user", connection.getUsername() ); + assertEquals( "secret123", connection.getPassword() ); + } + + @Test + public void testSubstituteConnectSql() { + connection.setConnectSql( "${SQL_INIT}" ); + + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, variableSpace ); + + assertEquals( "SET SESSION max_connections = 10;", connection.getConnectSql() ); + } + + @Test + public void testSubstituteSQLServerInstance() { + connection.setSQLServerInstance( "${DB_HOST}\\SQLEXPRESS" ); + + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, variableSpace ); + + assertEquals( "prod-host.example.com\\SQLEXPRESS", connection.getSQLServerInstance() ); + } + + @Test + public void testSubstituteDataTablespace() { + variableSpace.setVariable( "DATA_TS", "users_tablespace" ); + connection.setDataTablespace( "${DATA_TS}" ); + + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, variableSpace ); + + assertEquals( "users_tablespace", connection.getDataTablespace() ); + } + + @Test + public void testSubstituteIndexTablespace() { + variableSpace.setVariable( "INDEX_TS", "indexes_tablespace" ); + connection.setIndexTablespace( "${INDEX_TS}" ); + + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, variableSpace ); + + assertEquals( "indexes_tablespace", connection.getIndexTablespace() ); + } + + @Test + public void testSubstituteTablespacesWithMixedContent() { + variableSpace.setVariable( "DATA_TS", "users_ts" ); + variableSpace.setVariable( "INDEX_TS", "indexes_ts" ); + connection.setDataTablespace( "prefix_${DATA_TS}_suffix" ); + connection.setIndexTablespace( "idx_${INDEX_TS}_end" ); + + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, variableSpace ); + + assertEquals( "prefix_users_ts_suffix", connection.getDataTablespace() ); + assertEquals( "idx_indexes_ts_end", connection.getIndexTablespace() ); + } + + @Test + public void testSubstituteConnectionNameWithPrefix() { + connection.setName( "env_${CONN_NAME}_db" ); + + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, variableSpace ); + + assertEquals( "env_prod_db_connection_db", connection.getName() ); + } + + @Test + public void testSubstituteConnectionPoolingProperties() { + Map poolingProps = new HashMap<>(); + poolingProps.put( "maxPoolSize", "${POOL_SIZE}" ); + poolingProps.put( "minPoolSize", "5" ); + poolingProps.put( "timeout", "${DB_PORT}" ); // reuse a variable + + connection.setConnectionPoolingProperties( poolingProps ); + + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, variableSpace ); + + Map result = connection.getConnectionPoolingProperties(); + assertEquals( "20", result.get( "maxPoolSize" ) ); + assertEquals( "5", result.get( "minPoolSize" ) ); + assertEquals( "5432", result.get( "timeout" ) ); + } + + @Test + public void testNullFieldsAreNotModified() { + connection.setHostname( null ); + connection.setDatabaseName( null ); + connection.setDatabasePort( null ); + connection.setUsername( null ); + connection.setPassword( null ); + connection.setConnectSql( null ); + connection.setSQLServerInstance( null ); + + // Should not throw any exception + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, variableSpace ); + + assertNull( connection.getHostname() ); + assertNull( connection.getDatabaseName() ); + assertNull( connection.getDatabasePort() ); + assertNull( connection.getUsername() ); + assertNull( connection.getPassword() ); + assertNull( connection.getConnectSql() ); + assertNull( connection.getSQLServerInstance() ); + } + + @Test + public void testEmptyConnectionPoolingProperties() { + Map poolingProps = new HashMap<>(); + connection.setConnectionPoolingProperties( poolingProps ); + + // Should not throw any exception + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, variableSpace ); + + assertNotNull( connection.getConnectionPoolingProperties() ); + assertEquals( 0, connection.getConnectionPoolingProperties().size() ); + } + + @Test + public void testNullConnectionPoolingProperties() { + connection.setConnectionPoolingProperties( null ); + + // Should not throw any exception + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, variableSpace ); + + assertNull( connection.getConnectionPoolingProperties() ); + } + + @Test + public void testPartitionedConnectionSubstitution() { + connection.setHostname( "${DB_HOST}" ); + connection.setPartitioned( true ); + + // Create partitions + List partitions = new ArrayList<>(); + + PartitionDatabaseMeta partition1 = new PartitionDatabaseMeta(); + partition1.setHostname( "partition1-${DB_NAME}" ); + partition1.setPort( "${DB_PORT}" ); + partition1.setDatabaseName( "db_${DB_PORT}" ); + partitions.add( partition1 ); + + PartitionDatabaseMeta partition2 = new PartitionDatabaseMeta(); + partition2.setHostname( "${DB_HOST}" ); + partition2.setPort( "3306" ); + partition2.setDatabaseName( "${DB_NAME}" ); + partitions.add( partition2 ); + + connection.setPartitioningInformation( partitions ); + + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, variableSpace ); + + assertEquals( "prod-host.example.com", connection.getHostname() ); + assertEquals( "partition1-production_db", partition1.getHostname() ); + assertEquals( "5432", partition1.getPort() ); + assertEquals( "db_5432", partition1.getDatabaseName() ); + + assertEquals( "prod-host.example.com", partition2.getHostname() ); + assertEquals( "3306", partition2.getPort() ); + assertEquals( "production_db", partition2.getDatabaseName() ); + } + + @Test + public void testNullConnectionIsIgnored() { + // Should not throw any exception + DatabaseConnectionUtils.applyEnvironmentSubstitution( null, variableSpace ); + } + + @Test + public void testNullVariableSpaceIsIgnored() { + connection.setHostname( "${DB_HOST}" ); + + // Should not throw any exception + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, null ); + + // Connection should remain unchanged + assertEquals( "${DB_HOST}", connection.getHostname() ); + } + + @Test + public void testMixedSubstitutionAndLiterals() { + connection.setHostname( "host-${DB_HOST}.local" ); + connection.setDatabaseName( "db_prefix_${DB_NAME}" ); + connection.setDatabasePort( "3${DB_PORT}" ); + + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, variableSpace ); + + assertEquals( "host-prod-host.example.com.local", connection.getHostname() ); + assertEquals( "db_prefix_production_db", connection.getDatabaseName() ); + assertEquals( "35432", connection.getDatabasePort() ); + } + + @Test + public void testUnresolvedVariablesRemainUnchanged() { + connection.setHostname( "${UNKNOWN_VAR}" ); + connection.setDatabaseName( "db_${ANOTHER_UNKNOWN}" ); + + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, variableSpace ); + + // Kettle's environmentSubstitute returns the original string if variable is not found + assertEquals( "${UNKNOWN_VAR}", connection.getHostname() ); + assertEquals( "db_${ANOTHER_UNKNOWN}", connection.getDatabaseName() ); + } + + @Test + public void testConnectionPoolingPropertiesWithNullValues() { + Map poolingProps = new HashMap<>(); + poolingProps.put( "key1", "${POOL_SIZE}" ); + poolingProps.put( "key2", null ); + + connection.setConnectionPoolingProperties( poolingProps ); + + DatabaseConnectionUtils.applyEnvironmentSubstitution( connection, variableSpace ); + + Map result = connection.getConnectionPoolingProperties(); + assertEquals( "20", result.get( "key1" ) ); + assertNull( result.get( "key2" ) ); + } +} diff --git a/core/src/test/java/org/pentaho/platform/dataaccess/datasource/wizard/service/impl/ConnectionServiceRestApiTest.java b/core/src/test/java/org/pentaho/platform/dataaccess/datasource/wizard/service/impl/ConnectionServiceRestApiTest.java index 4a07c678e..1a91ed495 100644 --- a/core/src/test/java/org/pentaho/platform/dataaccess/datasource/wizard/service/impl/ConnectionServiceRestApiTest.java +++ b/core/src/test/java/org/pentaho/platform/dataaccess/datasource/wizard/service/impl/ConnectionServiceRestApiTest.java @@ -26,6 +26,7 @@ import jakarta.ws.rs.WebApplicationException; import java.util.Arrays; import java.util.List; +import java.util.UUID; import org.junit.Before; import org.junit.Test; import org.mockito.Mock; @@ -49,6 +50,7 @@ public class ConnectionServiceRestApiTest { private static final String CONN_NAME = "test_connection"; private static final String CONN_ID = "test-id-123"; + private static final String LEVEL_FIELD = "level"; @Mock private ConnectionServiceImpl connectionServiceImpl; @@ -481,6 +483,42 @@ public void testTestConnectionFailure() throws Exception { } } + /** + * Verifies that testConnection applies variable replacement before invoking the backend service. + */ + @Test + public void testTestConnectionResolvesVariablesBeforeBackendCall() throws Exception { + String varName = "PDI_CONN_HOST_" + UUID.randomUUID().toString().replace( '-', '_' ); + String varValue = "resolved-host.example.com"; + String original = System.getProperty( varName ); + try { + System.setProperty( varName, varValue ); + + DatabaseConnection conn = new DatabaseConnection(); + conn.setName( CONN_NAME ); + conn.setDatabaseName( "testdb" ); + conn.setHostname( "${" + varName + "}" ); + // Non-blank password bypasses saved-password lookup so the test is focused on substitution. + conn.setPassword( "set" ); + + when( connectionServiceImpl.testConnection( any( IDatabaseConnection.class ) ) ).thenAnswer( invocation -> { + IDatabaseConnection passed = invocation.getArgument( 0 ); + assertEquals( varValue, passed.getHostname() ); + return true; + } ); + + String result = connectionService.testConnection( conn, null ); + assertNotNull( result ); + assertTrue( result.toLowerCase().contains( "succeed" ) ); + } finally { + if ( original == null ) { + System.clearProperty( varName ); + } else { + System.setProperty( varName, original ); + } + } + } + /** * Tests updateConnection success case */ @@ -567,6 +605,47 @@ public void testGetConnections() throws Exception { assertEquals( "Should have one connection", 1, result.getDatabaseConnections().size() ); } + /** + * Tests getConnections always marks returned repository connections with level. + */ + @Test + public void testGetConnectionsAlwaysSetsRepositoryLevel() throws Exception { + IDatabaseConnection conn = createTestConnection(); + List connections = Arrays.asList( conn ); + + when( connectionServiceImpl.getConnections( true ) ).thenReturn( connections ); + + IDatabaseConnectionList result = connectionService.getConnections( null, true ); + + assertNotNull( "Result should not be null", result ); + assertEquals( "Should have one connection", 1, result.getDatabaseConnections().size() ); + assertEquals( "Repository level should always be set", + "Repository", result.getDatabaseConnections().get( 0 ).getLevel() ); + + String json = objectMapper.writeValueAsString( result.getDatabaseConnections().get( 0 ) ); + com.fasterxml.jackson.databind.JsonNode node = objectMapper.readTree( json ); + assertTrue( "JSON should contain top-level level field", node.has( LEVEL_FIELD ) ); + assertEquals( "Repository", node.get( LEVEL_FIELD ).asText() ); + } + + /** + * Tests getConnections always includes level field, even when allLevels is not set. + */ + @Test + public void testGetConnectionsWithoutAllLevelsStillSetsRepositoryLevel() throws Exception { + IDatabaseConnection conn = createTestConnection(); + List connections = Arrays.asList( conn ); + + when( connectionServiceImpl.getConnections( true ) ).thenReturn( connections ); + + IDatabaseConnectionList result = connectionService.getConnections( null, false ); + + assertNotNull( "Result should not be null", result ); + assertEquals( "Should have one connection", 1, result.getDatabaseConnections().size() ); + assertEquals( "Level should always be set to Repository", + "Repository", result.getDatabaseConnections().get( 0 ).getLevel() ); + } + /** * Tests isConnectionExist returns void on success */