From 5e82756fd83eaede9f36c798e712703721582681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20G=C4=99bal?= Date: Sun, 27 Sep 2026 14:09:59 +0300 Subject: [PATCH 1/6] Adding support for database connections via Oracle Wallet. --- README.md | 30 +++++- pom.xml | 8 ++ .../org/utplsql/cli/ConnectionConfig.java | 23 +++- .../org/utplsql/cli/DataSourceProvider.java | 13 ++- .../datasource/TestedDataSourceProvider.java | 15 ++- .../org/utplsql/cli/ConnectionConfigTest.java | 52 +++++++++ .../org/utplsql/cli/WalletConnectionIT.java | 101 ++++++++++++++++++ 7 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 src/test/java/org/utplsql/cli/WalletConnectionIT.java diff --git a/README.md b/README.md index 69444c2..3a66290 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,10 @@ Accepted formats: - `/@//[:]/` - `/@::` - `/@` +- `/@` - credentials are taken from an Oracle Wallet (Secure External Password Store), see [Oracle Wallet](#oracle-wallet-secure-external-password-store) -To connect using TNS, you need to have the ORACLE_HOME environment variable set. -The file tnsnames.ora must exist in path %ORACLE_HOME%/network/admin +To connect using TNS, you need to have either the TNS_ADMIN or the ORACLE_HOME environment variable set. +The file tnsnames.ora must exist in the TNS_ADMIN directory or in %ORACLE_HOME%/network/admin The file tnsnames.ora must contain valid TNS entries. In case you use a username containing `/` or a password containing `@` you should encapsulate it with double quotes `"`: @@ -108,6 +109,31 @@ In case you use a username containing `/` or a password containing `@` you shoul utplsql run "my/Username"/"myP@ssword"@connectstring ``` +#### Oracle Wallet (Secure External Password Store) + +To avoid passing the password on the command line, store the credentials in an Oracle Wallet and connect with `/@`: +``` +utplsql run /@MYDATABASE +``` + +Setup example: +``` +# create an auto-login wallet with credentials for TNS alias MYDATABASE +orapki wallet create -wallet $HOME/oracle/wallet -auto_login_local +mkstore -wrl $HOME/oracle/wallet -createCredential MYDATABASE someusername + +# point the JDBC driver to the wallet +echo "oracle.net.wallet_location=(SOURCE=(METHOD=FILE)(METHOD_DATA=(DIRECTORY=$HOME/oracle/wallet)))" \ + > $HOME/oracle/network/admin/ojdbc.properties + +# tnsnames.ora with the MYDATABASE entry must be in the same directory +export TNS_ADMIN=$HOME/oracle/network/admin +``` + +Instead of setting `TNS_ADMIN` you can also pass it as part of the connect string: `/@MYDATABASE?TNS_ADMIN=/path/to/network/admin`. + +The TNS alias used in the connect string must match the alias of the credential stored in the wallet. + ### run `utplsql run []` diff --git a/pom.xml b/pom.xml index 8e6966b..f182eb1 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,8 @@ 17 3.2.4 + + 23.7.0.25.01 5.12.2 4.7.7 @@ -73,6 +75,12 @@ utplsql-java-api ${utplsql-java-api.version} + + + com.oracle.database.security + oraclepki + ${oraclepki.version} + javax.xml.bind jaxb-api diff --git a/src/main/java/org/utplsql/cli/ConnectionConfig.java b/src/main/java/org/utplsql/cli/ConnectionConfig.java index 05e2de7..d0ce618 100644 --- a/src/main/java/org/utplsql/cli/ConnectionConfig.java +++ b/src/main/java/org/utplsql/cli/ConnectionConfig.java @@ -5,15 +5,21 @@ public class ConnectionConfig { + /** + * Either {@code /@} or {@code /@}. + */ + private static final Pattern CONNECT_STRING_PATTERN = + Pattern.compile("^(?:(\".+\"|[^/]+)/(\".+\"|[^@]+)|/)@(.*)$"); + private final String user; private final String password; private final String connect; public ConnectionConfig(String connectString) { - Matcher m = Pattern.compile("^(\".+\"|[^/]+)/(\".+\"|[^@]+)@(.*)$").matcher(connectString); + Matcher m = CONNECT_STRING_PATTERN.matcher(connectString); if (m.find()) { - user = stripEnclosingQuotes(m.group(1)); - password = stripEnclosingQuotes(m.group(2)); + user = m.group(1) == null ? null : stripEnclosingQuotes(m.group(1)); + password = m.group(2) == null ? null : stripEnclosingQuotes(m.group(2)); connect = m.group(3); } else { throw new IllegalArgumentException("Not a valid connectString: '" + connectString + "'"); @@ -42,7 +48,18 @@ public String getPassword() { return password; } + /** + * @return true when no user/password was given (connect string {@code /@}), + * meaning credentials are provided externally, e.g. by an Oracle Wallet + */ + public boolean isExternalAuthentication() { + return user == null; + } + public String getConnectString() { + if (isExternalAuthentication()) { + return "/@" + connect; + } return user + "/" + password + "@" + connect; } diff --git a/src/main/java/org/utplsql/cli/DataSourceProvider.java b/src/main/java/org/utplsql/cli/DataSourceProvider.java index 4eee258..c903a10 100644 --- a/src/main/java/org/utplsql/cli/DataSourceProvider.java +++ b/src/main/java/org/utplsql/cli/DataSourceProvider.java @@ -14,10 +14,15 @@ public class DataSourceProvider { static { - String oracleHome = System.getenv("ORACLE_HOME"); - if (oracleHome != null && System.getProperty("oracle.net.tns_admin") == null) { - System.setProperty("oracle.net.tns_admin", - String.join(File.separator, oracleHome, "NETWORK", "ADMIN")); + if (System.getProperty("oracle.net.tns_admin") == null) { + String tnsAdmin = System.getenv("TNS_ADMIN"); + String oracleHome = System.getenv("ORACLE_HOME"); + if (tnsAdmin != null && !tnsAdmin.isEmpty()) { + System.setProperty("oracle.net.tns_admin", tnsAdmin); + } else if (oracleHome != null) { + System.setProperty("oracle.net.tns_admin", + String.join(File.separator, oracleHome, "NETWORK", "ADMIN")); + } } } diff --git a/src/main/java/org/utplsql/cli/datasource/TestedDataSourceProvider.java b/src/main/java/org/utplsql/cli/datasource/TestedDataSourceProvider.java index 34fbb4b..69db592 100644 --- a/src/main/java/org/utplsql/cli/datasource/TestedDataSourceProvider.java +++ b/src/main/java/org/utplsql/cli/datasource/TestedDataSourceProvider.java @@ -49,8 +49,11 @@ private void setThickOrThinJdbcUrl(InitializableOracleDataSource ds) throws SQLE List errors = new ArrayList<>(); Throwable lastException = null; - ds.setUser(config.getUser()); - ds.setPassword(config.getPassword()); + // With external authentication (Oracle Wallet) the driver looks up the credentials itself + if (!config.isExternalAuthentication()) { + ds.setUser(config.getUser()); + ds.setPassword(config.getPassword()); + } for (ConnectStringPossibility possibility : possibilities) { logger.debug("Try connecting {}", possibility.getMaskedConnectString(config)); @@ -107,7 +110,7 @@ public String getConnectString(ConnectionConfig config) { @Override public String getMaskedConnectString(ConnectionConfig config) { - return "jdbc:oracle:oci8:****/****@" + config.getConnect(); + return "jdbc:oracle:oci8:" + maskedCredentials(config) + "@" + config.getConnect(); } } @@ -119,7 +122,11 @@ public String getConnectString(ConnectionConfig config) { @Override public String getMaskedConnectString(ConnectionConfig config) { - return "jdbc:oracle:thin:****/****@" + config.getConnect(); + return "jdbc:oracle:thin:" + maskedCredentials(config) + "@" + config.getConnect(); } } + + private static String maskedCredentials(ConnectionConfig config) { + return config.isExternalAuthentication() ? "/" : "****/****"; + } } diff --git a/src/test/java/org/utplsql/cli/ConnectionConfigTest.java b/src/test/java/org/utplsql/cli/ConnectionConfigTest.java index ee1e0b9..f32ad4d 100644 --- a/src/test/java/org/utplsql/cli/ConnectionConfigTest.java +++ b/src/test/java/org/utplsql/cli/ConnectionConfigTest.java @@ -1,6 +1,8 @@ package org.utplsql.cli; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import static org.junit.jupiter.api.Assertions.*; @@ -54,4 +56,54 @@ void parseSpecialCharsUser() { assertEquals("my.local.host/service", info.getConnect()); assertFalse(info.isSysDba()); } + + @Test + void parseCredentialsIsNotExternalAuthentication() { + ConnectionConfig info = new ConnectionConfig("test/pw@MY_TNS_ALIAS"); + + assertFalse(info.isExternalAuthentication()); + assertEquals("test/pw@MY_TNS_ALIAS", info.getConnectString()); + } + + @Test + void parseExternalAuthentication() { + ConnectionConfig info = new ConnectionConfig("/@MY_TNS_ALIAS"); + + assertNull(info.getUser()); + assertNull(info.getPassword()); + assertEquals("MY_TNS_ALIAS", info.getConnect()); + assertTrue(info.isExternalAuthentication()); + assertFalse(info.isSysDba()); + assertEquals("/@MY_TNS_ALIAS", info.getConnectString()); + } + + @Test + void parseExternalAuthenticationWithTnsAdminInUrl() { + ConnectionConfig info = new ConnectionConfig("/@MY_TNS_ALIAS?TNS_ADMIN=/home/me/oracle/network/admin"); + + assertNull(info.getUser()); + assertNull(info.getPassword()); + assertEquals("MY_TNS_ALIAS?TNS_ADMIN=/home/me/oracle/network/admin", info.getConnect()); + assertTrue(info.isExternalAuthentication()); + } + + @Test + void parseExternalAuthenticationWithEzConnect() { + ConnectionConfig info = new ConnectionConfig("/@//my.local.host:1521/service"); + + assertEquals("//my.local.host:1521/service", info.getConnect()); + assertTrue(info.isExternalAuthentication()); + } + + @ParameterizedTest + @ValueSource(strings = { + "/pw@MY_TNS_ALIAS", // password without user + "test/@MY_TNS_ALIAS", // user without password + "@MY_TNS_ALIAS", + "test@MY_TNS_ALIAS", + "MY_TNS_ALIAS" + }) + void rejectInvalidConnectString(String connectString) { + assertThrows(IllegalArgumentException.class, () -> new ConnectionConfig(connectString)); + } } diff --git a/src/test/java/org/utplsql/cli/WalletConnectionIT.java b/src/test/java/org/utplsql/cli/WalletConnectionIT.java new file mode 100644 index 0000000..857de8e --- /dev/null +++ b/src/test/java/org/utplsql/cli/WalletConnectionIT.java @@ -0,0 +1,101 @@ +package org.utplsql.cli; + +import oracle.security.pki.OracleSecretStore; +import oracle.security.pki.OracleWallet; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.utplsql.cli.datasource.TestedDataSourceProvider; + +import javax.sql.DataSource; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Connecting with {@code /@} using credentials stored in an Oracle Wallet + * (Secure External Password Store), see issue #225. + *

+ * The wallet, tnsnames.ora and ojdbc.properties are created on the fly from DB_URL / DB_USER / DB_PASS, + * so no Oracle client tooling (mkstore/orapki) is needed. + */ +class WalletConnectionIT { + + private static final String TNS_ALIAS = "UTPLSQL_CLI_WALLET"; + private static final Pattern EZ_CONNECT = Pattern.compile("^//([^:/]+)(?::(\\d+))?/(.+)$"); + + @TempDir + static Path tnsAdmin; + + private static String walletConnectString; + + @BeforeAll + static void createWallet() throws Exception { + Matcher m = EZ_CONNECT.matcher(TestHelper.getUrl()); + assumeTrue(m.matches(), "DB_URL must be in //host[:port]/service format to generate tnsnames.ora"); + String host = m.group(1); + String port = m.group(2) == null ? "1521" : m.group(2); + String service = m.group(3); + + Path walletDir = Files.createDirectories(tnsAdmin.resolve("wallet")); + char[] walletPassword = "Wallet_Pwd_123".toCharArray(); + + OracleWallet wallet = new OracleWallet(); + wallet.create(walletPassword); + OracleSecretStore secretStore = wallet.getSecretStore(); + secretStore.createCredential(TNS_ALIAS.toCharArray(), + TestHelper.getUser().toCharArray(), + TestHelper.getPass().toCharArray()); + wallet.setSecretStore(secretStore); + wallet.saveAs(walletDir.toString()); + wallet.createSSO(); + wallet.saveSSO(); + + assertTrue(Files.exists(walletDir.resolve("cwallet.sso")), "auto-login wallet was not created"); + + Files.writeString(tnsAdmin.resolve("tnsnames.ora"), + TNS_ALIAS + " = (DESCRIPTION = (ADDRESS = (PROTOCOL = TCP)(HOST = " + host + ")(PORT = " + port + "))" + + "(CONNECT_DATA = (SERVICE_NAME = " + service + ")))\n"); + Files.writeString(tnsAdmin.resolve("ojdbc.properties"), + "oracle.net.wallet_location=(SOURCE=(METHOD=FILE)(METHOD_DATA=(DIRECTORY=" + + forwardSlashes(walletDir) + ")))\n"); + + walletConnectString = "/@" + TNS_ALIAS + "?TNS_ADMIN=" + forwardSlashes(tnsAdmin); + } + + private static String forwardSlashes(Path path) { + return path.toAbsolutePath().toString().replace('\\', '/'); + } + + @Test + void connectsAsWalletUser() throws Exception { + ConnectionConfig config = new ConnectionConfig(walletConnectString); + DataSource dataSource = new TestedDataSourceProvider(config, 1).getDataSource(); + + try (Connection con = dataSource.getConnection(); + Statement stmt = con.createStatement(); + ResultSet rs = stmt.executeQuery("select user from dual")) { + assertTrue(rs.next()); + assertEquals(TestHelper.getUser().toUpperCase(), rs.getString(1)); + } + } + + @Test + void runCommandWithWallet() { + int result = TestHelper.runApp("run", + walletConnectString, + "-f=ut_documentation_reporter", + "-s", + "--failure-exit-code=0"); + + assertEquals(0, result); + } +} From b9abd0b73752318a3d5b593d46bd6b1111772c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20G=C4=99bal?= Date: Sun, 27 Sep 2026 14:15:25 +0300 Subject: [PATCH 2/6] Fixing minor warnings from code analysis. --- .../org/utplsql/cli/datasource/TestedDataSourceProvider.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/utplsql/cli/datasource/TestedDataSourceProvider.java b/src/main/java/org/utplsql/cli/datasource/TestedDataSourceProvider.java index 69db592..f871c03 100644 --- a/src/main/java/org/utplsql/cli/datasource/TestedDataSourceProvider.java +++ b/src/main/java/org/utplsql/cli/datasource/TestedDataSourceProvider.java @@ -87,7 +87,7 @@ private void setInitSqlFrom_NLS_LANG(InitializableOracleDataSource ds) { sqlCommands.add(String.format("ALTER SESSION SET NLS_TERRITORY='%s'", matcher.group(2))); } - if (sqlCommands.size() > 0) { + if (!sqlCommands.isEmpty()) { StringBuilder sb = new StringBuilder(); sb.append("BEGIN\n"); for (String command : sqlCommands) { @@ -95,7 +95,7 @@ private void setInitSqlFrom_NLS_LANG(InitializableOracleDataSource ds) { } sb.append("END;"); - logger.debug("NLS settings: {}", sb.toString()); + logger.debug("NLS settings: {}", sb); ds.setConnectionInitSql(sb.toString()); } } From e94f1a21fa67f99a247f30132d9938d0dc553654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20G=C4=99bal?= Date: Sun, 27 Sep 2026 15:20:02 +0300 Subject: [PATCH 3/6] Changes in the way TNS_ASMIN logic is handled as TNS_ADMIN is resolved automatically by jdbc driver. So `cli` will only use the ORACLE_HOME resolution when TNS_ADMIN is not defined. Removed `oraclepki` as pom dependency. It is now delivered together with jdbc as part of java-api 3.2.5. --- pom.xml | 10 +----- .../org/utplsql/cli/DataSourceProvider.java | 33 ++++++++++++----- .../utplsql/cli/DataSourceProviderTest.java | 36 +++++++++++++++++++ 3 files changed, 61 insertions(+), 18 deletions(-) create mode 100644 src/test/java/org/utplsql/cli/DataSourceProviderTest.java diff --git a/pom.xml b/pom.xml index f182eb1..5d2501b 100644 --- a/pom.xml +++ b/pom.xml @@ -14,9 +14,7 @@ UTF-8 17 - 3.2.4 - - 23.7.0.25.01 + 3.2.5-SNAPSHOT 5.12.2 4.7.7 @@ -75,12 +73,6 @@ utplsql-java-api ${utplsql-java-api.version} - - - com.oracle.database.security - oraclepki - ${oraclepki.version} - javax.xml.bind jaxb-api diff --git a/src/main/java/org/utplsql/cli/DataSourceProvider.java b/src/main/java/org/utplsql/cli/DataSourceProvider.java index c903a10..7d35bf3 100644 --- a/src/main/java/org/utplsql/cli/DataSourceProvider.java +++ b/src/main/java/org/utplsql/cli/DataSourceProvider.java @@ -13,17 +13,32 @@ */ public class DataSourceProvider { + private static final String TNS_ADMIN_PROPERTY = "oracle.net.tns_admin"; + static { - if (System.getProperty("oracle.net.tns_admin") == null) { - String tnsAdmin = System.getenv("TNS_ADMIN"); - String oracleHome = System.getenv("ORACLE_HOME"); - if (tnsAdmin != null && !tnsAdmin.isEmpty()) { - System.setProperty("oracle.net.tns_admin", tnsAdmin); - } else if (oracleHome != null) { - System.setProperty("oracle.net.tns_admin", - String.join(File.separator, oracleHome, "NETWORK", "ADMIN")); - } + String tnsAdminFallback = getTnsAdminFallback( + System.getProperty(TNS_ADMIN_PROPERTY), System.getenv("TNS_ADMIN"), System.getenv("ORACLE_HOME")); + if (tnsAdminFallback != null) { + System.setProperty(TNS_ADMIN_PROPERTY, tnsAdminFallback); + } + } + + /** + * The JDBC driver resolves tnsnames.ora / ojdbc.properties from the {@value TNS_ADMIN_PROPERTY} property + * or the TNS_ADMIN environment variable on its own, but it doesn't look into ORACLE_HOME. + * The property takes precedence over the environment variable, so it must not be set when TNS_ADMIN is. + * + * @return ORACLE_HOME/NETWORK/ADMIN when neither {@value TNS_ADMIN_PROPERTY} nor TNS_ADMIN is set, otherwise null + */ + static String getTnsAdminFallback(String tnsAdminProperty, String tnsAdminEnv, String oracleHome) { + if (isEmpty(tnsAdminProperty) && isEmpty(tnsAdminEnv) && !isEmpty(oracleHome)) { + return String.join(File.separator, oracleHome, "NETWORK", "ADMIN"); } + return null; + } + + private static boolean isEmpty(String value) { + return value == null || value.isEmpty(); } public static DataSource getDataSource(String connectString, int maxConnections) throws SQLException { diff --git a/src/test/java/org/utplsql/cli/DataSourceProviderTest.java b/src/test/java/org/utplsql/cli/DataSourceProviderTest.java new file mode 100644 index 0000000..cfe918e --- /dev/null +++ b/src/test/java/org/utplsql/cli/DataSourceProviderTest.java @@ -0,0 +1,36 @@ +package org.utplsql.cli; + +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class DataSourceProviderTest { + + private static final String ORACLE_HOME = "/opt/oracle"; + + @Test + void tnsAdminFallsBackToOracleHome() { + assertEquals(String.join(File.separator, ORACLE_HOME, "NETWORK", "ADMIN"), + DataSourceProvider.getTnsAdminFallback(null, null, ORACLE_HOME)); + } + + @Test + void tnsAdminEnvVariableIsLeftToJdbcDriver() { + // Setting the property would override the TNS_ADMIN environment variable in the JDBC driver + assertNull(DataSourceProvider.getTnsAdminFallback(null, "/my/tns_admin", ORACLE_HOME)); + } + + @Test + void tnsAdminPropertyIsNotOverridden() { + assertNull(DataSourceProvider.getTnsAdminFallback("/my/tns_admin", null, ORACLE_HOME)); + } + + @Test + void noFallbackWithoutOracleHome() { + assertNull(DataSourceProvider.getTnsAdminFallback(null, null, null)); + assertNull(DataSourceProvider.getTnsAdminFallback("", "", "")); + } +} From 11f2ae88f3fd141b3b1e041c76cb4342753922ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20G=C4=99bal?= Date: Sun, 27 Sep 2026 15:38:07 +0300 Subject: [PATCH 4/6] Fixing paths as they should be lowercase. --- src/main/java/org/utplsql/cli/DataSourceProvider.java | 4 ++-- src/test/java/org/utplsql/cli/DataSourceProviderTest.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/utplsql/cli/DataSourceProvider.java b/src/main/java/org/utplsql/cli/DataSourceProvider.java index 7d35bf3..d3dbfcc 100644 --- a/src/main/java/org/utplsql/cli/DataSourceProvider.java +++ b/src/main/java/org/utplsql/cli/DataSourceProvider.java @@ -28,11 +28,11 @@ public class DataSourceProvider { * or the TNS_ADMIN environment variable on its own, but it doesn't look into ORACLE_HOME. * The property takes precedence over the environment variable, so it must not be set when TNS_ADMIN is. * - * @return ORACLE_HOME/NETWORK/ADMIN when neither {@value TNS_ADMIN_PROPERTY} nor TNS_ADMIN is set, otherwise null + * @return ORACLE_HOME/network/admin (lowercase, as in Oracle installations; paths are case-sensitive on Linux) when neither {@value TNS_ADMIN_PROPERTY} nor TNS_ADMIN is set, otherwise null */ static String getTnsAdminFallback(String tnsAdminProperty, String tnsAdminEnv, String oracleHome) { if (isEmpty(tnsAdminProperty) && isEmpty(tnsAdminEnv) && !isEmpty(oracleHome)) { - return String.join(File.separator, oracleHome, "NETWORK", "ADMIN"); + return String.join(File.separator, oracleHome, "network", "admin"); } return null; } diff --git a/src/test/java/org/utplsql/cli/DataSourceProviderTest.java b/src/test/java/org/utplsql/cli/DataSourceProviderTest.java index cfe918e..8e274f6 100644 --- a/src/test/java/org/utplsql/cli/DataSourceProviderTest.java +++ b/src/test/java/org/utplsql/cli/DataSourceProviderTest.java @@ -13,7 +13,7 @@ class DataSourceProviderTest { @Test void tnsAdminFallsBackToOracleHome() { - assertEquals(String.join(File.separator, ORACLE_HOME, "NETWORK", "ADMIN"), + assertEquals(String.join(File.separator, ORACLE_HOME, "network", "admin"), DataSourceProvider.getTnsAdminFallback(null, null, ORACLE_HOME)); } From 5561a05528470b847f937a76bc15dae563dadd8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20G=C4=99bal?= Date: Sun, 27 Sep 2026 15:42:02 +0300 Subject: [PATCH 5/6] Additional tests to verify different ways to resolve TNS name. --- .../org/utplsql/cli/WalletConnectionIT.java | 87 +++++++++++++++++-- 1 file changed, 79 insertions(+), 8 deletions(-) diff --git a/src/test/java/org/utplsql/cli/WalletConnectionIT.java b/src/test/java/org/utplsql/cli/WalletConnectionIT.java index 857de8e..4b1d5eb 100644 --- a/src/test/java/org/utplsql/cli/WalletConnectionIT.java +++ b/src/test/java/org/utplsql/cli/WalletConnectionIT.java @@ -8,16 +8,21 @@ import org.utplsql.cli.datasource.TestedDataSourceProvider; import javax.sql.DataSource; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.sql.Connection; import java.sql.ResultSet; import java.sql.Statement; +import java.util.Map; +import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assumptions.assumeTrue; /** @@ -26,15 +31,22 @@ *

* The wallet, tnsnames.ora and ojdbc.properties are created on the fly from DB_URL / DB_USER / DB_PASS, * so no Oracle client tooling (mkstore/orapki) is needed. + *

+ * TNS_ADMIN and ORACLE_HOME are read once per JVM and environment variables can't be changed at runtime, + * so the scenarios depending on them run the CLI in a separate JVM with a controlled environment. */ class WalletConnectionIT { private static final String TNS_ALIAS = "UTPLSQL_CLI_WALLET"; private static final Pattern EZ_CONNECT = Pattern.compile("^//([^:/]+)(?::(\\d+))?/(.+)$"); + private static final long SUBPROCESS_TIMEOUT_MINUTES = 3; @TempDir - static Path tnsAdmin; + static Path tempDir; + private static Path tnsAdmin; + private static Path oracleHome; + private static Path emptyOracleHome; private static String walletConnectString; @BeforeAll @@ -45,7 +57,7 @@ static void createWallet() throws Exception { String port = m.group(2) == null ? "1521" : m.group(2); String service = m.group(3); - Path walletDir = Files.createDirectories(tnsAdmin.resolve("wallet")); + Path walletDir = Files.createDirectories(tempDir.resolve("wallet")); char[] walletPassword = "Wallet_Pwd_123".toCharArray(); OracleWallet wallet = new OracleWallet(); @@ -61,16 +73,26 @@ static void createWallet() throws Exception { assertTrue(Files.exists(walletDir.resolve("cwallet.sso")), "auto-login wallet was not created"); - Files.writeString(tnsAdmin.resolve("tnsnames.ora"), - TNS_ALIAS + " = (DESCRIPTION = (ADDRESS = (PROTOCOL = TCP)(HOST = " + host + ")(PORT = " + port + "))" - + "(CONNECT_DATA = (SERVICE_NAME = " + service + ")))\n"); - Files.writeString(tnsAdmin.resolve("ojdbc.properties"), - "oracle.net.wallet_location=(SOURCE=(METHOD=FILE)(METHOD_DATA=(DIRECTORY=" - + forwardSlashes(walletDir) + ")))\n"); + String tnsnamesOra = TNS_ALIAS + " = (DESCRIPTION = (ADDRESS = (PROTOCOL = TCP)(HOST = " + host + ")(PORT = " + port + "))" + + "(CONNECT_DATA = (SERVICE_NAME = " + service + ")))\n"; + String ojdbcProperties = "oracle.net.wallet_location=(SOURCE=(METHOD=FILE)(METHOD_DATA=(DIRECTORY=" + + forwardSlashes(walletDir) + ")))\n"; + + tnsAdmin = writeNetworkConfig(tempDir.resolve("tns_admin"), tnsnamesOra, ojdbcProperties); + oracleHome = tempDir.resolve("oracle_home"); + writeNetworkConfig(oracleHome.resolve("network").resolve("admin"), tnsnamesOra, ojdbcProperties); + emptyOracleHome = Files.createDirectories(tempDir.resolve("empty_oracle_home")); walletConnectString = "/@" + TNS_ALIAS + "?TNS_ADMIN=" + forwardSlashes(tnsAdmin); } + private static Path writeNetworkConfig(Path dir, String tnsnamesOra, String ojdbcProperties) throws IOException { + Files.createDirectories(dir); + Files.writeString(dir.resolve("tnsnames.ora"), tnsnamesOra); + Files.writeString(dir.resolve("ojdbc.properties"), ojdbcProperties); + return dir; + } + private static String forwardSlashes(Path path) { return path.toAbsolutePath().toString().replace('\\', '/'); } @@ -98,4 +120,53 @@ void runCommandWithWallet() { assertEquals(0, result); } + + @Test + void runCommandWithTnsAdminEnvironmentVariable() throws Exception { + assertCliConnectsWithWallet(Map.of("TNS_ADMIN", tnsAdmin.toString())); + } + + @Test + void runCommandWithOracleHomeFallback() throws Exception { + assertCliConnectsWithWallet(Map.of("ORACLE_HOME", oracleHome.toString())); + } + + @Test + void tnsAdminEnvironmentVariableTakesPrecedenceOverOracleHome() throws Exception { + // Fails if ORACLE_HOME/network/admin (without tnsnames.ora) is used instead of TNS_ADMIN + assertCliConnectsWithWallet(Map.of( + "TNS_ADMIN", tnsAdmin.toString(), + "ORACLE_HOME", emptyOracleHome.toString())); + } + + /** + * Runs {@code utplsql run /@} in a separate JVM with TNS_ADMIN and ORACLE_HOME + * replaced by the given environment + */ + private void assertCliConnectsWithWallet(Map environment) throws Exception { + Path java = Paths.get(System.getProperty("java.home"), "bin", "java"); + ProcessBuilder processBuilder = new ProcessBuilder( + java.toString(), "-cp", System.getProperty("java.class.path"), + Cli.class.getName(), "run", "/@" + TNS_ALIAS, + "-f=ut_documentation_reporter", "-s", "--failure-exit-code=0"); + + processBuilder.environment().remove("TNS_ADMIN"); + processBuilder.environment().remove("ORACLE_HOME"); + processBuilder.environment().putAll(environment); + + Path output = Files.createTempFile(tempDir, "cli-output", ".log"); + processBuilder.redirectErrorStream(true); + processBuilder.redirectOutput(output.toFile()); + + Process process = processBuilder.start(); + if (!process.waitFor(SUBPROCESS_TIMEOUT_MINUTES, TimeUnit.MINUTES)) { + process.destroyForcibly(); + fail("CLI did not finish within " + SUBPROCESS_TIMEOUT_MINUTES + " minutes. Output:\n" + Files.readString(output)); + } + + String cliOutput = Files.readString(output); + assertEquals(0, process.exitValue(), () -> "CLI failed with environment " + environment + ". Output:\n" + cliOutput); + assertTrue(cliOutput.contains("Use connection string jdbc:oracle:thin:/@" + TNS_ALIAS), + () -> "Expected wallet connection with environment " + environment + ". Output:\n" + cliOutput); + } } From 96f9f9f221cca3ffb0db45cbd95e9b89f4ded41e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20G=C4=99bal?= Date: Sun, 27 Sep 2026 15:44:35 +0300 Subject: [PATCH 6/6] Update of readme.md --- README.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3a66290..f3a2895 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,15 @@ Accepted formats: - `/@` - `/@` - credentials are taken from an Oracle Wallet (Secure External Password Store), see [Oracle Wallet](#oracle-wallet-secure-external-password-store) -To connect using TNS, you need to have either the TNS_ADMIN or the ORACLE_HOME environment variable set. -The file tnsnames.ora must exist in the TNS_ADMIN directory or in %ORACLE_HOME%/network/admin -The file tnsnames.ora must contain valid TNS entries. +To connect using a TNS name, the file `tnsnames.ora` with a valid entry for that name must be found. +The directory holding `tnsnames.ora` (and `ojdbc.properties`, if used) is taken from the first of these that is set: + +1. `TNS_ADMIN` parameter in the connect string, e.g. `app/pass@MYDATABASE?TNS_ADMIN=/path/to/network/admin` +2. Java system property `oracle.net.tns_admin`, e.g. `export JAVA_OPTS="-Doracle.net.tns_admin=/path/to/network/admin"` +3. `TNS_ADMIN` environment variable +4. `$ORACLE_HOME/network/admin`, when the `ORACLE_HOME` environment variable is set + +Options 1-3 are handled by the Oracle JDBC driver. Option 4 is a fallback provided by utPLSQL-cli, used only when none of the others is set. In case you use a username containing `/` or a password containing `@` you should encapsulate it with double quotes `"`: ``` @@ -126,11 +132,11 @@ mkstore -wrl $HOME/oracle/wallet -createCredential MYDATABASE someusername echo "oracle.net.wallet_location=(SOURCE=(METHOD=FILE)(METHOD_DATA=(DIRECTORY=$HOME/oracle/wallet)))" \ > $HOME/oracle/network/admin/ojdbc.properties -# tnsnames.ora with the MYDATABASE entry must be in the same directory +# tnsnames.ora with the MYDATABASE entry must be in the same directory as ojdbc.properties export TNS_ADMIN=$HOME/oracle/network/admin ``` -Instead of setting `TNS_ADMIN` you can also pass it as part of the connect string: `/@MYDATABASE?TNS_ADMIN=/path/to/network/admin`. +`ojdbc.properties` is read from the same directory as `tnsnames.ora`, so any of the options listed under [ConnectionURL](#connectionurl) can be used instead of `TNS_ADMIN`, for example `utplsql run "/@MYDATABASE?TNS_ADMIN=/path/to/network/admin"`. The TNS alias used in the connect string must match the alias of the credential stored in the wallet.