From 432c7756efeca6e6b4ccb023502a2b961fa18272 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 25 Sep 2026 15:00:01 +0300 Subject: [PATCH] [#1075] Test the JDBC guards against a neighbouring MySQL database and Oracle schema Pin each layer that keeps a table or index of another database of the server from answering for one of this backend's in openTree(): - JDBCStorageRetryTest: the MySQL guards pass the database of the connection as the catalog; covers() drops a row of another database; under databaseTerm=SCHEMA the schema path alone tells the neighbour apart. - MySqlTestCase: a live case over a second database of the same server, under the default databaseTerm and under databaseTerm=SCHEMA. - OracleTestCase: a live case over another user's schema, visible to the suite through a grant. - TestCase: drop such a neighbour left by a killed run before dropStaleTrees(), which would otherwise skip the whole class on every later run. Fixes #1075 --- .../backends/jdbc/JDBCStorageRetryTest.java | 85 ++++++++++++ .../server/backends/jdbc/MySqlTestCase.java | 123 +++++++++++++++++ .../server/backends/jdbc/OracleTestCase.java | 129 +++++++++++++++++- .../opends/server/backends/jdbc/TestCase.java | 18 +++ 4 files changed, 352 insertions(+), 3 deletions(-) diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/JDBCStorageRetryTest.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/JDBCStorageRetryTest.java index 45b0ebb9df..14dc2cc96f 100644 --- a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/JDBCStorageRetryTest.java +++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/JDBCStorageRetryTest.java @@ -1135,6 +1135,91 @@ public void testTheIndexGuardNamesTheTableAsWrittenWhenTheDatabaseStoresItSo() t verify(metaData).getIndexInfo(any(), any(), eq(storage.getTableName(TREE)), anyBoolean(), anyBoolean()); } + /** + * On mysql the guards of {@code openTree()} ask in the database of the connection. A table name carries no + * database, so two directories on one server - the stock backend id in two databases - hold the same table + * and the same index, and Connector/J reads a null catalog as "any database": asked that way, the index of + * the neighbour answers for this one and the create behind it is skipped for good, leaving every + * {@code where k>? order by k} batch of every cursor a full scan (#1075). The catalog passed here is one of + * two layers - {@code TableScope.covers()} reads the database of every row besides, as the case below pins - + * and a live server cannot tell a loss of either one from their both holding, since each covers for the + * other: what is pinned here is the question put to the driver. + */ + @Test + public void testTheMySqlGuardsAskInTheDatabaseOfTheConnection() throws Exception + { + final JDBCStorage storage = storageOverAnEngine(mysqlConnection.class, true); + when(engineConnection.getCatalog()).thenReturn(THIS_DATABASE); + final DatabaseMetaData metaData = engineConnection.getMetaData(); + + storage.write(txn -> txn.openTree(TREE, true)); + + verify(metaData, atLeastOnce()).getTables(eq(THIS_DATABASE), any(), eq(storage.getTableName(TREE)), any()); + verify(metaData).getIndexInfo(eq(THIS_DATABASE), any(), eq(storage.getTableName(TREE)), anyBoolean(), + anyBoolean()); + } + + /** + * The other layer of the two: an index a driver reports in another database is not this backend's, however + * it came to be listed - a driver ignoring the catalog it is given, or a caller no longer passing one. Found + * there, it must leave the create of this database's own index to go ahead (#1075). + */ + @Test + public void testAnIndexOfAnotherDatabaseAnswersForNoneOfThisOne() throws Exception + { + final JDBCStorage storage = storageOverAnEngine(mysqlConnection.class, true); + when(engineConnection.getCatalog()).thenReturn(THIS_DATABASE); + answerTheIndexFrom(engineConnection.getMetaData(), storage, NEIGHBOUR_DATABASE, null); + + storage.write(txn -> txn.openTree(TREE, true)); + + verify(engineConnection).prepareStatement(startsWith("create index k_")); + } + + /** + * And the same under {@code databaseTerm=SCHEMA}, the setting of Connector/J that names the database a + * schema: the connection then names no catalog, the lookup is asked of the whole server, and every row comes + * back under the catalog {@code def} with the database in its schema - measured against mysql 9.2 with the + * Connector/J this backend ships (#1075). The schema path {@code TableScope} reads off the connection is the + * only thing left to tell the neighbour's index from this one's; {@code MySqlTestCase} pins how the driver + * lists such a row against a live server. + */ + @Test + public void testUnderDatabaseTermSchemaAnIndexOfAnotherDatabaseAnswersForNoneOfThisOne() throws Exception + { + final JDBCStorage storage = storageOverAnEngine(mysqlConnection.class, true); + when(engineConnection.getSchema()).thenReturn(THIS_DATABASE); + answerTheIndexFrom(engineConnection.getMetaData(), storage, "def", NEIGHBOUR_DATABASE); + + storage.write(txn -> txn.openTree(TREE, true)); + + verify(engineConnection).prepareStatement(startsWith("create index k_")); + } + + /** The database of the connection of the three cases above, and that of the directory next to it. */ + private static final String THIS_DATABASE = "this_directory"; + private static final String NEIGHBOUR_DATABASE = "neighbour_directory"; + + /** + * Has the index lookup of the fixture report the {@code k_} index of the table of {@link #TREE} as the one of + * the given catalog and schema, the way a driver lists the index of another database of the server. + */ + private static void answerTheIndexFrom(DatabaseMetaData metaData, JDBCStorage storage, String catalog, + String schema) throws SQLException + { + final String tableName = storage.getTableName(TREE); + // stubbed over the answer of the fixture with doAnswer(): when() would call that answer, which stubs a + // result set of its own in the middle of this stubbing + doAnswer(invocation -> { + final ResultSet indexes = mock(ResultSet.class); + when(indexes.next()).thenReturn(true, false); + when(indexes.getString("INDEX_NAME")).thenReturn("k_" + tableName.substring("opendj_".length())); + when(indexes.getString("TABLE_CAT")).thenReturn(catalog); + when(indexes.getString("TABLE_SCHEM")).thenReturn(schema); + return indexes; + }).when(metaData).getIndexInfo(any(), any(), any(), anyBoolean(), anyBoolean()); + } + /** * postgresql runs DDL inside the transaction, so a create index the engine rolled back has committed nothing: * {@code write()} rolls the attempt back whole and replays it. Raising the flag in front of the statement - diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/MySqlTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/MySqlTestCase.java index a3d325c4c7..edf6fc32a9 100644 --- a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/MySqlTestCase.java +++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/MySqlTestCase.java @@ -15,12 +15,20 @@ */ package org.opends.server.backends.jdbc; +import org.forgerock.opendj.ldap.ByteString; +import org.opends.server.backends.pluggable.spi.AccessMode; +import org.opends.server.backends.pluggable.spi.TreeName; +import org.opends.server.backends.pluggable.spi.WriteOperation; +import org.opends.server.backends.pluggable.spi.WriteableTransaction; import org.testcontainers.containers.JdbcDatabaseContainer; import org.testcontainers.containers.MySQLContainer; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import java.sql.Connection; import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; @@ -174,6 +182,121 @@ private static SQLException spendTheHourlyQueries(String url) throws SQLExceptio throw new AssertionError("an account granted one query an hour must be refused within four connects"); } + /** The database of the case below: a directory next to the one of this suite, on the same server. */ + private static final String NEIGHBOUR = "opendj_neighbour1075"; + + @Override + protected void dropStaleNeighbours() throws SQLException { + grant(getJdbcUrl(), "drop database if exists " + NEIGHBOUR); + } + + @DataProvider + public Object[][] databaseTerms() { + return new Object[][] { + // the default of Connector/J: the database is the catalog, and the lookups are asked in it + { "catalog", "" }, + // the database is the schema: the connection names no catalog, the lookups span the server, + // and only the schema path read off the connection tells the neighbour apart + { "schema", "?databaseTerm=SCHEMA" }, + }; + } + + /** + * A table and an index of another database of this server answer for none of this backend's, however + * the connection names its database (#1075) - the mysql twin of the postgres case of #902. + *

+ * A table is named after its tree and an index after its table, so two directories on one server - the + * stock backend id in two databases - hold the same table and the same index. Connector/J asked with no + * catalog lists the tables of every database, and both guards of {@code openTree()} would then skip a + * create this backend needs: the table one leaves every statement addressing a table that is not there, + * and the index one - the quiet half - leaves the {@code where k>? order by k} batches of every cursor + * a full scan for the life of the deployment. + *

+ * Under the default {@code databaseTerm} the guards hold by two layers at once - the catalog they pass + * and the database {@code TableScope.covers()} reads off every row - and this case goes red only when + * both give way; the unit cases of {@code JDBCStorageRetryTest} pin each of them. Under + * {@code databaseTerm=SCHEMA} the connection names no catalog and the schema path is the one layer + * there is, which is what this case exists for: what it rests on is how the driver lists a table of + * another database, and only a live server answers that. + */ + @Test(dataProvider = "databaseTerms") + public void testAnOpenIsAnsweredForByNoTableOfAnotherDatabase(String term, String urlOptions) throws Exception { + final TreeName tree = new TreeName("testAnotherDatabase", "tree"); + final JDBCStorage storage = + new JDBCStorage(createBackendCfg(getBackendId() + "_" + term, getJdbcUrl() + urlOptions), null); + final String tableName = storage.getTableName(tree); + final String indexName = "k_" + tableName.substring("opendj_".length()); + try { + // the neighbouring directory: the same table and the same index, in a database this storage + // reaches through no unqualified name of its own. Spelled out rather than opened by a storage, + // so that the fixture is the collision and nothing else + grant(getJdbcUrl(), "drop database if exists " + NEIGHBOUR, + "create database " + NEIGHBOUR, + "create table " + NEIGHBOUR + "." + tableName + + " (h char(128),k varbinary(255),v longblob,primary key(h,k))", + "create index " + indexName + " on " + NEIGHBOUR + "." + tableName + " (k)"); + assertFalse(isExistsIn(DATABASE, tableName, null), + "the case did not start with the table of this backend absent from its database"); + + storage.open(AccessMode.READ_WRITE); + storage.write(new WriteOperation() { + @Override + public void run(WriteableTransaction txn) throws Exception { + txn.openTree(tree, true); + // the destructive half of the table guard, and the reason it is loud: found abroad, the + // table is created nowhere and this statement addresses a table that is not there + txn.put(tree, ByteString.valueOfUtf8("a key of this backend"), + ByteString.valueOfUtf8("a value of this backend")); + } + }); + + assertTrue(isExistsIn(DATABASE, tableName, null), + "the open took the table of another database for its own and created none"); + assertTrue(isExistsIn(DATABASE, tableName, indexName), + "the open took the index of another database for its own: the cursor batches of this tree are full scans behind it"); + assertEquals(rowCountIn(DATABASE, tableName), 1, + "the write of this backend landed in a table other than the one the open made"); + assertEquals(rowCountIn(NEIGHBOUR, tableName), 0, + "the write of this backend landed in the table of the neighbouring database"); + } finally { + clearQuietly(storage); + grant(getJdbcUrl(), "drop database if exists " + NEIGHBOUR); + } + } + + /** The database of the connections of this suite. */ + private static final String DATABASE = "database_name"; + + /** + * Whether that one database holds the table - or, given an index name, that index of it - asked of + * information_schema by name rather than through the catalog lookups under test. + */ + private boolean isExistsIn(String database, String tableName, String indexName) throws SQLException { + final String query = indexName == null + ? "select 1 from information_schema.tables where table_schema=? and table_name=?" + : "select 1 from information_schema.statistics where table_schema=? and table_name=? and index_name=?"; + try (final Connection con = DriverManager.getConnection(getJdbcUrl()); + final PreparedStatement st = con.prepareStatement(query)) { + st.setString(1, database); + st.setString(2, tableName); + if (indexName != null) { + st.setString(3, indexName); + } + try (final ResultSet rs = st.executeQuery()) { + return rs.next(); + } + } + } + + /** What the table of that one database holds, which says which of the two tables a write went to. */ + private int rowCountIn(String database, String tableName) throws SQLException { + try (final Connection con = DriverManager.getConnection(getJdbcUrl()); + final Statement st = con.createStatement(); + final ResultSet rs = st.executeQuery("select count(*) from " + database + "." + tableName)) { + return rs.next() ? rs.getInt(1) : -1; + } + } + /** The account of the test is made and unmade on the connection of the suite's own credentials. */ private static void grant(String url, String... statements) throws SQLException { try (final Connection admin = DriverManager.getConnection(url); diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/OracleTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/OracleTestCase.java index 0b9547de10..69b923ad71 100644 --- a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/OracleTestCase.java +++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/OracleTestCase.java @@ -11,17 +11,32 @@ * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions Copyright [year] [name of copyright owner]". * - * Copyright 2025 3A Systems, LLC. + * Copyright 2025-2026 3A Systems, LLC. */ package org.opends.server.backends.jdbc; +import org.forgerock.opendj.ldap.ByteString; +import org.opends.server.backends.pluggable.spi.AccessMode; +import org.opends.server.backends.pluggable.spi.TreeName; +import org.opends.server.backends.pluggable.spi.WriteOperation; +import org.opends.server.backends.pluggable.spi.WriteableTransaction; import org.testcontainers.containers.JdbcDatabaseContainer; import org.testcontainers.oracle.OracleContainer; import org.testng.annotations.Test; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; import java.time.Duration; -//docker run --rm --name oracle-db -p 1521:1521 -e APP_USER=opendj -e ORACLE_DATABASE=database_name -e APP_USER_PASSWORD=password gvenzl/oracle-free:23.26.2-slim-faststart +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +//docker run --rm --name oracle-db -p 1521:1521 -e APP_USER=opendj -e ORACLE_DATABASE=database_name -e APP_USER_PASSWORD=password -e ORACLE_PASSWORD=password gvenzl/oracle-free:23.26.2-slim-faststart @Test(sequential = true) public class OracleTestCase extends TestCase { @@ -39,7 +54,7 @@ protected JdbcDatabaseContainer getContainer() { @Override protected String getContainerDockerCommand() { - return "run before test: docker run --rm --name oracle-db -p 1521:1521 -e APP_USER=opendj -e ORACLE_DATABASE=database_name -e APP_USER_PASSWORD=password gvenzl/oracle-free:23.26.2-slim-faststart"; + return "run before test: docker run --rm --name oracle-db -p 1521:1521 -e APP_USER=opendj -e ORACLE_DATABASE=database_name -e APP_USER_PASSWORD=password -e ORACLE_PASSWORD=password gvenzl/oracle-free:23.26.2-slim-faststart"; } @Override @@ -52,6 +67,114 @@ protected String getJdbcUrl() { return "jdbc:oracle:thin:opendj/password@localhost: " + ((container==null)?"1521":container.getMappedPort(1521)) + "/database_name"; } + /** The schema of the case below: another user of this database, holding a directory of its own. */ + private static final String NEIGHBOUR = "opendj_neighbour1075"; + + /** + * The administrator of the database, who makes the neighbour and grants this suite a look at it: the + * container is given one password for both accounts, and the user of the suite may create no other. + */ + private String getSystemJdbcUrl() { + return getJdbcUrl().replace("opendj/password", "system/password"); + } + + @Override + protected void dropStaleNeighbours() throws SQLException { + administer("drop user if exists " + NEIGHBOUR + " cascade"); + } + + /** + * A table and an index of another schema answer for none of this backend's (#1075) - the oracle twin + * of the postgres case of #902. + *

+ * A table is named after its tree and an index after its table, so two directories in two schemas of + * one database hold the same table and the same index. Oracle names no catalog, so a lookup of + * {@code openTree()} is asked of every schema the user may see, and the schema {@code TableScope} + * reads off the connection is the one thing telling the neighbour's index from this one's: found + * abroad, the table guard leaves every statement of the backend addressing a table that is not there, + * and the index one leaves the {@code where k>? order by k} batches of every cursor a full scan. + *

+ * The grant is the whole of the fixture: the dictionary views the driver reads list another user's + * table only to a user granted something on it, and without it the lookup finds nothing either way. + */ + @Test + public void testAnOpenIsAnsweredForByNoTableOfAnotherSchema() throws Exception { + final TreeName tree = new TreeName("testAnotherSchema", "tree"); + final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_anotherSchema"), null); + final String tableName = storage.getTableName(tree); + final String indexName = "k_" + tableName.substring("opendj_".length()); + try { + // the neighbouring directory: the same table and the same index, in a schema this storage + // reaches through no unqualified name of its own. Spelled out rather than opened by a storage, + // so that the fixture is the collision and nothing else + administer("drop user if exists " + NEIGHBOUR + " cascade", + "create user " + NEIGHBOUR + " identified by password quota unlimited on users", + "create table " + NEIGHBOUR + "." + tableName + " (h char(128),k raw(2000),v blob,primary key(h,k))", + "create index " + NEIGHBOUR + "." + indexName + " on " + NEIGHBOUR + "." + tableName + " (k)", + "grant select on " + NEIGHBOUR + "." + tableName + " to opendj"); + assertFalse(isExistsOwn("select 1 from user_tables where table_name=upper(?)", tableName), + "the case did not start with the table of this backend absent from its schema"); + + storage.open(AccessMode.READ_WRITE); + storage.write(new WriteOperation() { + @Override + public void run(WriteableTransaction txn) throws Exception { + txn.openTree(tree, true); + // the destructive half of the table guard, and the reason it is loud: found abroad, the + // table is created nowhere and this statement addresses a table that is not there + txn.put(tree, ByteString.valueOfUtf8("a key of this backend"), + ByteString.valueOfUtf8("a value of this backend")); + } + }); + + assertTrue(isExistsOwn("select 1 from user_tables where table_name=upper(?)", tableName), + "the open took the table of another schema for its own and created none"); + assertTrue(isExistsOwn("select 1 from user_indexes where index_name=upper(?)", indexName), + "the open took the index of another schema for its own: the cursor batches of this tree are full scans behind it"); + assertEquals(rowCount(tableName), 1, + "the write of this backend landed in a table other than the one the open made"); + assertEquals(rowCount(NEIGHBOUR + "." + tableName), 0, + "the write of this backend landed in the table of the neighbouring schema"); + } finally { + clearQuietly(storage); + administer("drop user if exists " + NEIGHBOUR + " cascade"); + } + } + + /** Runs the given statements as the administrator of the database. */ + private void administer(String... statements) throws SQLException { + try (final Connection con = DriverManager.getConnection(getSystemJdbcUrl()); + final Statement st = con.createStatement()) { + for (final String statement : statements) { + st.execute(statement); + } + } + } + + /** + * Whether the dictionary of the user of this suite lists the named object, asked by name rather than + * through the catalog lookups under test. + */ + private boolean isExistsOwn(String query, String name) throws SQLException { + try (final Connection con = DriverManager.getConnection(getJdbcUrl()); + final PreparedStatement st = con.prepareStatement(query)) { + st.setString(1, name); + try (final ResultSet rs = st.executeQuery()) { + return rs.next(); + } + } + } + + /** What the named table holds, read as the administrator, who reaches both schemas. */ + private int rowCount(String qualified) throws SQLException { + final String table = qualified.contains(".") ? qualified : "opendj." + qualified; + try (final Connection con = DriverManager.getConnection(getSystemJdbcUrl()); + final Statement st = con.createStatement(); + final ResultSet rs = st.executeQuery("select count(*) from " + table)) { + return rs.next() ? rs.getInt(1) : -1; + } + } + @Override @Test(skipFailedInvocations = true) //ORA UPSERT error public void test_issue_496_2() { diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java index f3b35005a1..d22681d692 100644 --- a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java +++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java @@ -97,6 +97,12 @@ public void setUp() throws Exception { throw new SkipException(getContainerDockerCommand()); } } + try { + dropStaleNeighbours(); + } catch (SQLException e) { + // nothing left behind to drop, or an account the suite cannot log in with: either way the + // listing below decides, and it fails only where such a leftover is actually there + } try(Connection con = DriverManager.getConnection(createBackendCfg().getDBDirectory())){ dropStaleTrees(con); } catch (Exception e) { @@ -135,6 +141,18 @@ static void dropStaleTrees(Connection con) throws SQLException { } } + /** + * Drops what a case of a suite made outside the database or the schema of its own connections - a + * neighbouring directory of the same table names - where a run killed in the middle of that case left + * it behind. It goes before {@link #dropStaleTrees}, whose listing reaches such a table and whose drop + * then fails - unqualified on mysql, where the table is in another database, and without the privilege + * on oracle, where it is another user's - skipping the whole class on every run after it. A container + * the suite starts is a fresh one; the database of {@link #getContainerDockerCommand()}, which a run + * without docker falls back on, outlives every run made against it. + */ + protected void dropStaleNeighbours() throws SQLException { + } + @Override protected Backend createBackend() { return new Backend();