diff --git a/.gitignore b/.gitignore index 0909ef4e..5686daa2 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,5 @@ local.properties addDevTag addReleaseTag .vscode -*.iml \ No newline at end of file +*.iml +pg_stat_monitor_output \ No newline at end of file diff --git a/src/main/java/io/supertokens/storage/postgresql/ConnectionPool.java b/src/main/java/io/supertokens/storage/postgresql/ConnectionPool.java index 8ebb7af3..00567642 100644 --- a/src/main/java/io/supertokens/storage/postgresql/ConnectionPool.java +++ b/src/main/java/io/supertokens/storage/postgresql/ConnectionPool.java @@ -73,7 +73,8 @@ private synchronized void initialiseHikariDataSource() throws SQLException, Stor attributes = "?" + attributes; } - config.setJdbcUrl("jdbc:" + scheme + "://" + hostName + port + "/" + databaseName + attributes); + String jdbcUrl = "jdbc:" + scheme + "://" + hostName + port + "/" + databaseName + attributes; + config.setJdbcUrl(jdbcUrl); if (userConfig.getUser() != null) { config.setUsername(userConfig.getUser()); diff --git a/src/main/java/io/supertokens/storage/postgresql/Start.java b/src/main/java/io/supertokens/storage/postgresql/Start.java index fea0050f..7811ebff 100644 --- a/src/main/java/io/supertokens/storage/postgresql/Start.java +++ b/src/main/java/io/supertokens/storage/postgresql/Start.java @@ -20,8 +20,10 @@ import java.lang.reflect.Field; import java.sql.BatchUpdateException; import java.sql.Connection; +import java.sql.DriverManager; import java.sql.SQLException; import java.sql.SQLTransactionRollbackException; +import java.sql.Statement; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -1106,9 +1108,98 @@ public void addInfoToNonAuthRecipesBasedOnUserId(TenantIdentifier tenantIdentifi } } + // Track which auxiliary databases have already been DROP'd + CREATE'd in this JVM. + // The first call per DB name does a full DROP + CREATE for clean state between tests. + // Subsequent calls for the same DB name skip the DROP/CREATE entirely — avoiding the ~5s + // block that occurs when DROP DATABASE hits a database with active HikariCP connections. + private static final java.util.Set ensuredDatabases = java.util.concurrent.ConcurrentHashMap.newKeySet(); + @Override public void modifyConfigToAddANewUserPoolForTesting(JsonObject config, int poolNumber) { - config.add("postgresql_database_name", new JsonPrimitive("st" + poolNumber)); + // Use worker-specific database names to avoid conflicts during parallel test execution + String workerId = System.getProperty("org.gradle.test.worker", ""); + String dbName = workerId.isEmpty() ? "st" + poolNumber : "st" + poolNumber + "_w" + workerId; + + // Only auto-create databases for standard pool numbers (0-50). + // Higher pool numbers (like 1000) are used in tests that expect the database + // NOT to exist (for testing error handling). + if (poolNumber >= 0 && poolNumber <= 50) { + if (ensuredDatabases.add(dbName)) { + // First time seeing this DB in this JVM — do the full DROP + CREATE + ensureTestDatabaseExists(dbName, config); + } + // Otherwise, the DB was already created earlier in this JVM — skip + } + + config.add("postgresql_database_name", new JsonPrimitive(dbName)); + } + + /** + * Helper method to get configuration values from environment variables or system properties. + */ + private static String getConfigValue(String name, String defaultValue) { + String value = System.getenv(name); + if (value == null || value.isEmpty()) { + value = System.getProperty(name); + } + return (value != null && !value.isEmpty()) ? value : defaultValue; + } + + /** + * Ensures a test database exists, creating it if necessary. + * This is called during test setup to create auxiliary databases for multitenancy tests. + */ + private void ensureTestDatabaseExists(String dbName, JsonObject config) { + // Get connection info from config or use defaults + // Check both environment variables and system properties, matching DatabaseTestHelper behavior + String host = config.has("postgresql_host") + ? config.get("postgresql_host").getAsString() + : getConfigValue("TEST_PG_HOST", "localhost"); + String port = config.has("postgresql_port") + ? String.valueOf(config.get("postgresql_port").getAsInt()) + : getConfigValue("TEST_PG_PORT", getConfigValue("ST_POSTGRESQL_PLUGIN_SERVER_PORT", "5432")); + String user = config.has("postgresql_user") + ? config.get("postgresql_user").getAsString() + : getConfigValue("TEST_PG_USER", "root"); + String password = config.has("postgresql_password") + ? config.get("postgresql_password").getAsString() + : getConfigValue("TEST_PG_PASSWORD", "root"); + + String adminUrl = "jdbc:postgresql://" + host + ":" + port + "/postgres"; + + try { + // Ensure driver is loaded + Class.forName("org.postgresql.Driver"); + } catch (ClassNotFoundException e) { + // Driver should already be available + return; + } + + try (Connection conn = DriverManager.getConnection(adminUrl, user, password); + Statement stmt = conn.createStatement()) { + + // Terminate any lingering connections from previous test runs, then drop for clean state. + try { + stmt.executeUpdate( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '" + dbName + "' AND pid <> pg_backend_pid()"); + } catch (SQLException ignored) { + // pg_stat_activity query might fail on some setups + } + + try { + stmt.executeUpdate("DROP DATABASE IF EXISTS " + dbName); + } catch (SQLException ignored) { + // Ignore errors - database might still have connections + } + + // Create fresh database + stmt.executeUpdate("CREATE DATABASE " + dbName); + + } catch (SQLException e) { + // Database might already exist or creation failed - log but don't fail + // The actual connection attempt will surface any real issues + System.err.println("[Start] Warning: Could not create test database " + dbName + ": " + e.getMessage()); + } } @Override diff --git a/src/main/java/io/supertokens/storage/postgresql/config/PostgreSQLConfig.java b/src/main/java/io/supertokens/storage/postgresql/config/PostgreSQLConfig.java index eed6a799..36f2c076 100644 --- a/src/main/java/io/supertokens/storage/postgresql/config/PostgreSQLConfig.java +++ b/src/main/java/io/supertokens/storage/postgresql/config/PostgreSQLConfig.java @@ -198,6 +198,7 @@ public class PostgreSQLConfig { defaultValue = "null", isOptional = true, isEditable = true) private Integer postgresql_minimum_idle_connections = null; + @IgnoreForAnnotationCheck boolean isValidAndNormalised = false; @@ -402,6 +403,7 @@ public Integer getMinimumIdleConnections() { return postgresql_minimum_idle_connections; } + public String getThirdPartyUserToTenantTable() { return addSchemaAndPrefixToTableName("thirdparty_user_to_tenant"); } @@ -685,7 +687,7 @@ private void validateAndNormalise(boolean skipValidation) throws InvalidConfigEx { // postgresql_host if (postgresql_host == null) { - postgresql_host = "localhost"; + postgresql_host = System.getProperty("ST_POSTGRESQL_PLUGIN_SERVER_HOST", "localhost"); } } diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/AccountInfoQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/AccountInfoQueries.java index 2b8bb816..02a06be4 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/AccountInfoQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/AccountInfoQueries.java @@ -424,7 +424,7 @@ public static CanBecomePrimaryResult checkIfLoginMethodCanBecomePrimary(Start st if (primaryUserId[0] != null) { if (primaryUserId[0].equals(recipeUserId)) { - return CanBecomePrimaryResult.wasAlreadyAPrimeryUserResult(); + return CanBecomePrimaryResult.wasAlreadyAPrimaryUserResult(); } else { return CanBecomePrimaryResult.linkedWithAnotherPrimaryUserResult(primaryUserId[0]); } @@ -1346,6 +1346,151 @@ public static void reservePrimaryUserAccountInfos_Transaction(Start start, Trans executeBatch(sqlCon, QUERY, primaryUserTenantSetters); } + + // ── Lookup queries (migrated from per-recipe queries) ── + + /** + * Find all primary_or_recipe_user_ids that have a matching email in the given tenant. + * Replaces 4 separate per-recipe queries (emailpassword, passwordless, thirdparty, webauthn). + */ + public static List listPrimaryUserIdsByEmail(Start start, TenantIdentifier tenantIdentifier, + String email) + throws SQLException, StorageQueryException { + String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id" + + " FROM " + getConfig(start).getRecipeUserTenantsTable() + " rut" + + " JOIN " + getConfig(start).getAppIdToUserIdTable() + " auid" + + " ON rut.app_id = auid.app_id AND rut.recipe_user_id = auid.user_id" + + " WHERE rut.app_id = ? AND rut.tenant_id = ?" + + " AND rut.account_info_type = ? AND rut.account_info_value = ?"; + + return execute(start, QUERY, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, tenantIdentifier.getTenantId()); + pst.setString(3, ACCOUNT_INFO_TYPE.EMAIL.toString()); + pst.setString(4, email); + }, result -> { + List userIds = new ArrayList<>(); + while (result.next()) { + userIds.add(result.getString("primary_or_recipe_user_id")); + } + return userIds; + }); + } + + /** + * Find all primary_or_recipe_user_ids that have a matching phone number in the given tenant. + * Replaces PasswordlessQueries.getPrimaryUserByPhoneNumber(). + */ + public static List listPrimaryUserIdsByPhoneNumber(Start start, TenantIdentifier tenantIdentifier, + String phoneNumber) + throws SQLException, StorageQueryException { + String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id" + + " FROM " + getConfig(start).getRecipeUserTenantsTable() + " rut" + + " JOIN " + getConfig(start).getAppIdToUserIdTable() + " auid" + + " ON rut.app_id = auid.app_id AND rut.recipe_user_id = auid.user_id" + + " WHERE rut.app_id = ? AND rut.tenant_id = ?" + + " AND rut.account_info_type = ? AND rut.account_info_value = ?"; + + return execute(start, QUERY, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, tenantIdentifier.getTenantId()); + pst.setString(3, ACCOUNT_INFO_TYPE.PHONE_NUMBER.toString()); + pst.setString(4, phoneNumber); + }, result -> { + List userIds = new ArrayList<>(); + while (result.next()) { + userIds.add(result.getString("primary_or_recipe_user_id")); + } + return userIds; + }); + } + + /** + * Find the primary_or_recipe_user_id for a thirdparty user by provider info in a tenant. + * Replaces ThirdPartyQueries.getUserIdByThirdPartyInfo(). + */ + public static String getPrimaryUserIdByThirdPartyInfo(Start start, TenantIdentifier tenantIdentifier, + String thirdPartyId, String thirdPartyUserId) + throws SQLException, StorageQueryException { + String accountInfoValue = thirdPartyId + "::" + thirdPartyUserId; + String QUERY = "SELECT auid.primary_or_recipe_user_id" + + " FROM " + getConfig(start).getRecipeUserTenantsTable() + " rut" + + " JOIN " + getConfig(start).getAppIdToUserIdTable() + " auid" + + " ON rut.app_id = auid.app_id AND rut.recipe_user_id = auid.user_id" + + " WHERE rut.app_id = ? AND rut.tenant_id = ?" + + " AND rut.account_info_type = ? AND rut.account_info_value = ?" + + " LIMIT 1"; + + return execute(start, QUERY, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, tenantIdentifier.getTenantId()); + pst.setString(3, ACCOUNT_INFO_TYPE.THIRD_PARTY.toString()); + pst.setString(4, accountInfoValue); + }, result -> { + if (result.next()) { + return result.getString("primary_or_recipe_user_id"); + } + return null; + }); + } + + /** + * Find all primary_or_recipe_user_ids for a thirdparty provider info across all tenants in an app. + * Replaces ThirdPartyQueries.listUserIdsByThirdPartyInfo(). + * Uses recipe_user_account_infos (app-scoped) instead of recipe_user_tenants (tenant-scoped). + */ + public static List listPrimaryUserIdsByThirdPartyInfo(Start start, AppIdentifier appIdentifier, + String thirdPartyId, String thirdPartyUserId) + throws SQLException, StorageQueryException { + String accountInfoValue = thirdPartyId + "::" + thirdPartyUserId; + String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id" + + " FROM " + getConfig(start).getRecipeUserAccountInfosTable() + " ruai" + + " JOIN " + getConfig(start).getAppIdToUserIdTable() + " auid" + + " ON ruai.app_id = auid.app_id AND ruai.recipe_user_id = auid.user_id" + + " WHERE ruai.app_id = ?" + + " AND ruai.account_info_type = ? AND ruai.account_info_value = ?"; + + return execute(start, QUERY, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, ACCOUNT_INFO_TYPE.THIRD_PARTY.toString()); + pst.setString(3, accountInfoValue); + }, result -> { + List userIds = new ArrayList<>(); + while (result.next()) { + userIds.add(result.getString("primary_or_recipe_user_id")); + } + return userIds; + }); + } + + /** + * Transaction variant of listPrimaryUserIdsByThirdPartyInfo. + */ + public static List listPrimaryUserIdsByThirdPartyInfo_Transaction(Start start, Connection sqlCon, + AppIdentifier appIdentifier, + String thirdPartyId, + String thirdPartyUserId) + throws SQLException, StorageQueryException { + String accountInfoValue = thirdPartyId + "::" + thirdPartyUserId; + String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id" + + " FROM " + getConfig(start).getRecipeUserAccountInfosTable() + " ruai" + + " JOIN " + getConfig(start).getAppIdToUserIdTable() + " auid" + + " ON ruai.app_id = auid.app_id AND ruai.recipe_user_id = auid.user_id" + + " WHERE ruai.app_id = ?" + + " AND ruai.account_info_type = ? AND ruai.account_info_value = ?"; + + return execute(sqlCon, QUERY, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, ACCOUNT_INFO_TYPE.THIRD_PARTY.toString()); + pst.setString(3, accountInfoValue); + }, result -> { + List userIds = new ArrayList<>(); + while (result.next()) { + userIds.add(result.getString("primary_or_recipe_user_id")); + } + return userIds; + }); + } } diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/EmailPasswordQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/EmailPasswordQueries.java index f1a16f8a..10b7479b 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/EmailPasswordQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/EmailPasswordQueries.java @@ -64,7 +64,7 @@ static String getQueryToCreateUsersTable(Start start) { + "CONSTRAINT " + Utils.getConstraintName(schema, emailPasswordUsersTable, "user_id", "fkey") + " FOREIGN KEY(app_id, user_id)" + " REFERENCES " + Config.getConfig(start).getAppIdToUserIdTable() + - " (app_id, user_id) ON DELETE CASCADE," + " (app_id, user_id) ON DELETE CASCADE ON UPDATE CASCADE," + "CONSTRAINT " + Utils.getConstraintName(schema, emailPasswordUsersTable, null, "pkey") + " PRIMARY KEY (app_id, user_id)" + ");"; @@ -291,12 +291,15 @@ public static AuthRecipeUserInfo signUp(Start start, TenantIdentifier tenantIden try { { // app_id_to_user_id String QUERY = "INSERT INTO " + getConfig(start).getAppIdToUserIdTable() - + "(app_id, user_id, primary_or_recipe_user_id, recipe_id)" + " VALUES(?, ?, ?, ?)"; + + "(app_id, user_id, primary_or_recipe_user_id, recipe_id, time_joined, primary_or_recipe_user_time_joined)" + + " VALUES(?, ?, ?, ?, ?, ?)"; update(sqlCon, QUERY, pst -> { pst.setString(1, tenantIdentifier.getAppId()); pst.setString(2, userId); pst.setString(3, userId); pst.setString(4, EMAIL_PASSWORD.toString()); + pst.setLong(5, timeJoined); + pst.setLong(6, timeJoined); }); } @@ -362,7 +365,8 @@ public static void importUsers_Transaction(Start start, Connection sqlCon, List< throws StorageQueryException, StorageTransactionLogicException { try { String app_id_to_user_id_QUERY = "INSERT INTO " + getConfig(start).getAppIdToUserIdTable() - + "(app_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id)" + " VALUES(?, ?, ?, ?, ?)"; + + "(app_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id, time_joined, primary_or_recipe_user_time_joined)" + + " VALUES(?, ?, ?, ?, ?, ?, ?)"; String all_auth_recipe_users_QUERY = "INSERT INTO " + getConfig(start).getUsersTable() + "(app_id, tenant_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id, time_joined, " + @@ -403,6 +407,8 @@ public static void importUsers_Transaction(Start start, Connection sqlCon, List< pst.setString(3, primaryOrRecipeUserId); pst.setBoolean(4, isLinkedOrIsPrimaryUser); pst.setString(5, EMAIL_PASSWORD.toString()); + pst.setLong(6, user.timeJoinedMSSinceEpoch); + pst.setLong(7, user.timeJoinedMSSinceEpoch); }); emailPasswordUsersSetters.add(pst -> { @@ -576,11 +582,12 @@ public static List getUsersInfoUsingIdList_Transaction(Start start, public static String getPrimaryUserIdUsingEmail(Start start, TenantIdentifier tenantIdentifier, String email) throws StorageQueryException, SQLException { - String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id " - + "FROM " + getConfig(start).getEmailPasswordUserToTenantTable() + " AS ep" + - " JOIN " + getConfig(start).getUsersTable() + " AS all_users" + - " ON ep.app_id = all_users.app_id AND ep.user_id = all_users.user_id" + - " WHERE ep.app_id = ? AND ep.tenant_id = ? AND ep.email = ?"; + String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id AS user_id " + + "FROM " + getConfig(start).getRecipeUserTenantsTable() + " AS rut" + + " JOIN " + getConfig(start).getAppIdToUserIdTable() + " AS auid" + + " ON rut.app_id = auid.app_id AND rut.recipe_user_id = auid.user_id" + + " WHERE rut.app_id = ? AND rut.tenant_id = ? AND rut.account_info_type = 'email'" + + " AND rut.account_info_value = ? AND rut.recipe_id = 'emailpassword'"; return execute(start, QUERY, pst -> { pst.setString(1, tenantIdentifier.getAppId()); diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java index 1ef37b7c..c67bc983 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java @@ -121,11 +121,11 @@ static String getQueryToCreateUsersTable(Start start) { + "CONSTRAINT " + Utils.getConstraintName(schema, usersTable, "primary_or_recipe_user_id", "fkey") + " FOREIGN KEY(app_id, primary_or_recipe_user_id)" + " REFERENCES " + Config.getConfig(start).getAppIdToUserIdTable() + - " (app_id, user_id) ON DELETE CASCADE," + " (app_id, user_id) ON DELETE CASCADE ON UPDATE CASCADE," + "CONSTRAINT " + Utils.getConstraintName(schema, usersTable, "user_id", "fkey") + " FOREIGN KEY(app_id, user_id)" + " REFERENCES " + Config.getConfig(start).getAppIdToUserIdTable() + - " (app_id, user_id) ON DELETE CASCADE" + " (app_id, user_id) ON DELETE CASCADE ON UPDATE CASCADE" + ");"; // @formatter:on } @@ -261,12 +261,14 @@ private static String getQueryToCreateAppIdToUserIdTable(Start start) { + "recipe_id VARCHAR(128) NOT NULL," + "primary_or_recipe_user_id CHAR(36) NOT NULL," + "is_linked_or_is_a_primary_user BOOLEAN NOT NULL DEFAULT FALSE," + + "time_joined BIGINT NOT NULL DEFAULT 0," + + "primary_or_recipe_user_time_joined BIGINT NOT NULL DEFAULT 0," + "CONSTRAINT " + Utils.getConstraintName(schema, appToUserTable, null, "pkey") + " PRIMARY KEY (app_id, user_id), " + "CONSTRAINT " + Utils.getConstraintName(schema, appToUserTable, "primary_or_recipe_user_id", "fkey") + " FOREIGN KEY(app_id, primary_or_recipe_user_id)" + " REFERENCES " + Config.getConfig(start).getAppIdToUserIdTable() + - " (app_id, user_id) ON DELETE CASCADE," + " (app_id, user_id) ON DELETE CASCADE ON UPDATE CASCADE," + "CONSTRAINT " + Utils.getConstraintName(schema, appToUserTable, "app_id", "fkey") + " FOREIGN KEY(app_id) REFERENCES " + Config.getConfig(start).getAppsTable() + " (app_id) ON DELETE CASCADE" @@ -289,6 +291,30 @@ static String getQueryToCreateUserIdIndexForAppIdToUserIdTable(Start start) { + Config.getConfig(start).getAppIdToUserIdTable() + "(user_id, app_id);"; } + static String getQueryToCreateAppIdToUserIdPaginationIndex1(Start start) { + return "CREATE INDEX IF NOT EXISTS app_id_to_user_id_pagination_index1 ON " + + Config.getConfig(start).getAppIdToUserIdTable() + + "(app_id, primary_or_recipe_user_time_joined DESC, primary_or_recipe_user_id DESC);"; + } + + static String getQueryToCreateAppIdToUserIdPaginationIndex2(Start start) { + return "CREATE INDEX IF NOT EXISTS app_id_to_user_id_pagination_index2 ON " + + Config.getConfig(start).getAppIdToUserIdTable() + + "(app_id, primary_or_recipe_user_time_joined ASC, primary_or_recipe_user_id DESC);"; + } + + static String getQueryToCreateAppIdToUserIdPaginationIndex3(Start start) { + return "CREATE INDEX IF NOT EXISTS app_id_to_user_id_pagination_index3 ON " + + Config.getConfig(start).getAppIdToUserIdTable() + + "(recipe_id, app_id, primary_or_recipe_user_time_joined DESC, primary_or_recipe_user_id DESC);"; + } + + static String getQueryToCreateAppIdToUserIdPaginationIndex4(Start start) { + return "CREATE INDEX IF NOT EXISTS app_id_to_user_id_pagination_index4 ON " + + Config.getConfig(start).getAppIdToUserIdTable() + + "(recipe_id, app_id, primary_or_recipe_user_time_joined ASC, primary_or_recipe_user_id DESC);"; + } + public static void createTablesIfNotExists(Start start, Connection con) throws SQLException, StorageQueryException { int numberOfRetries = 0; boolean retry = true; @@ -324,6 +350,10 @@ public static void createTablesIfNotExists(Start start, Connection con) throws S update(con, getQueryToCreateAppIdIndexForAppIdToUserIdTable(start), NO_OP_SETTER); update(con, getQueryToCreatePrimaryUserIdIndexForAppIdToUserIdTable(start), NO_OP_SETTER); update(con, getQueryToCreateUserIdIndexForAppIdToUserIdTable(start), NO_OP_SETTER); + update(con, getQueryToCreateAppIdToUserIdPaginationIndex1(start), NO_OP_SETTER); + update(con, getQueryToCreateAppIdToUserIdPaginationIndex2(start), NO_OP_SETTER); + update(con, getQueryToCreateAppIdToUserIdPaginationIndex3(start), NO_OP_SETTER); + update(con, getQueryToCreateAppIdToUserIdPaginationIndex4(start), NO_OP_SETTER); } if (!doesTableExists(start, con, Config.getConfig(start).getUsersTable())) { @@ -942,7 +972,7 @@ public static long getUsersCount(Start start, AppIdentifier appIdentifier, RECIP throws SQLException, StorageQueryException { StringBuilder QUERY = new StringBuilder( "SELECT COUNT(*) AS total FROM ("); - QUERY.append("SELECT primary_or_recipe_user_id FROM " + getConfig(start).getUsersTable()); + QUERY.append("SELECT primary_or_recipe_user_id FROM " + getConfig(start).getAppIdToUserIdTable()); QUERY.append(" WHERE app_id = ?"); if (includeRecipeIds != null && includeRecipeIds.length > 0) { QUERY.append(" AND recipe_id IN ("); @@ -977,10 +1007,11 @@ public static long getUsersCount(Start start, TenantIdentifier tenantIdentifier, throws SQLException, StorageQueryException { StringBuilder QUERY = new StringBuilder( "SELECT COUNT(*) AS total FROM ("); - QUERY.append("SELECT primary_or_recipe_user_id FROM " + getConfig(start).getUsersTable()); - QUERY.append(" WHERE app_id = ? AND tenant_id = ?"); + QUERY.append("SELECT auid.primary_or_recipe_user_id FROM " + getConfig(start).getRecipeUserTenantsTable() + " rut"); + QUERY.append(" JOIN " + getConfig(start).getAppIdToUserIdTable() + " auid ON rut.app_id = auid.app_id AND rut.recipe_user_id = auid.user_id"); + QUERY.append(" WHERE rut.app_id = ? AND rut.tenant_id = ?"); if (includeRecipeIds != null && includeRecipeIds.length > 0) { - QUERY.append(" AND recipe_id IN ("); + QUERY.append(" AND rut.recipe_id IN ("); for (int i = 0; i < includeRecipeIds.length; i++) { QUERY.append("?"); if (i != includeRecipeIds.length - 1) { @@ -991,7 +1022,7 @@ public static long getUsersCount(Start start, TenantIdentifier tenantIdentifier, QUERY.append(")"); } - QUERY.append(" GROUP BY primary_or_recipe_user_id) AS uniq_users"); + QUERY.append(" GROUP BY auid.primary_or_recipe_user_id) AS uniq_users"); return execute(start, QUERY.toString(), pst -> { pst.setString(1, tenantIdentifier.getAppId()); @@ -1039,10 +1070,10 @@ public static boolean doesUserIdExist(Start start, TenantIdentifier tenantIdenti throws SQLException, StorageQueryException { // We query both tables cause there is a case where a primary user ID exists, but its associated // recipe user ID has been deleted AND there are other recipe user IDs linked to this primary user ID already. - String QUERY = "SELECT 1 FROM " + getConfig(start).getUsersTable() - + " WHERE app_id = ? AND tenant_id = ? AND user_id = ? UNION SELECT 1 FROM " + - getConfig(start).getUsersTable() + - " WHERE app_id = ? AND tenant_id = ? AND primary_or_recipe_user_id = ?"; + String QUERY = "SELECT 1 FROM " + getConfig(start).getRecipeUserTenantsTable() + + " WHERE app_id = ? AND tenant_id = ? AND recipe_user_id = ? UNION SELECT 1 FROM " + + getConfig(start).getPrimaryUserTenantsTable() + + " WHERE app_id = ? AND tenant_id = ? AND primary_user_id = ?"; return execute(start, QUERY, pst -> { pst.setString(1, tenantIdentifier.getAppId()); pst.setString(2, tenantIdentifier.getTenantId()); @@ -1085,207 +1116,147 @@ public static AuthRecipeUserInfo[] getUsers(Start start, TenantIdentifier tenant List usersFromQuery; if (dashboardSearchTags != null) { - ArrayList queryList = new ArrayList<>(); - { - StringBuilder USER_SEARCH_TAG_CONDITION = new StringBuilder(); - - { - // check if we should search through the emailpassword table - if (dashboardSearchTags.shouldEmailPasswordTableBeSearched()) { - String QUERY = "SELECT allAuthUsersTable.*" + " FROM " + getConfig(start).getUsersTable() - + " AS allAuthUsersTable" + - " JOIN " + getConfig(start).getEmailPasswordUserToTenantTable() - + " AS emailpasswordTable ON allAuthUsersTable.app_id = emailpasswordTable.app_id AND " - + "allAuthUsersTable.tenant_id = emailpasswordTable.tenant_id AND " - + "allAuthUsersTable.user_id = emailpasswordTable.user_id"; - - // attach email tags to queries - QUERY = QUERY + - " WHERE (emailpasswordTable.app_id = ? AND emailpasswordTable.tenant_id = ?) AND" - + " ( emailpasswordTable.email LIKE ? OR emailpasswordTable.email LIKE ? "; - queryList.add(tenantIdentifier.getAppId()); - queryList.add(tenantIdentifier.getTenantId()); - queryList.add(dashboardSearchTags.emails.get(0) + "%"); - queryList.add("%@" + dashboardSearchTags.emails.get(0) + "%"); - for (int i = 1; i < dashboardSearchTags.emails.size(); i++) { - QUERY += " OR emailpasswordTable.email LIKE ? OR emailpasswordTable.email LIKE ?"; - queryList.add(dashboardSearchTags.emails.get(i) + "%"); - queryList.add("%@" + dashboardSearchTags.emails.get(i) + "%"); - } - - QUERY += " )"; + boolean hasEmails = dashboardSearchTags.emails != null; + boolean hasPhones = dashboardSearchTags.phoneNumbers != null; + boolean hasProviders = dashboardSearchTags.providers != null; - USER_SEARCH_TAG_CONDITION.append("SELECT * FROM ( ").append(QUERY) - .append(" LIMIT 1000) AS emailpasswordResultTable"); + if (!hasEmails && !hasPhones && !hasProviders) { + usersFromQuery = new ArrayList<>(); + } else { + ArrayList queryParams = new ArrayList<>(); + + StringBuilder query = new StringBuilder( + "SELECT DISTINCT auid.primary_or_recipe_user_id," + + " auid.primary_or_recipe_user_time_joined" + + " FROM " + getConfig(start).getAppIdToUserIdTable() + " auid" + + " JOIN " + getConfig(start).getRecipeUserTenantsTable() + " rut" + + " ON auid.app_id = rut.app_id AND auid.user_id = rut.recipe_user_id"); + + if (hasEmails && hasPhones) { + // Email + Phone: self-join needed (only passwordless users have both) + query.append(" JOIN ").append(getConfig(start).getRecipeUserTenantsTable()).append(" rut_phone") + .append(" ON auid.app_id = rut_phone.app_id AND auid.user_id = rut_phone.recipe_user_id") + .append(" AND rut_phone.tenant_id = rut.tenant_id"); + } + + query.append(" WHERE rut.app_id = ? AND rut.tenant_id = ?"); + queryParams.add(tenantIdentifier.getAppId()); + queryParams.add(tenantIdentifier.getTenantId()); + + if (hasEmails && hasPhones) { + // Email condition on rut + query.append(" AND rut.account_info_type = 'email' AND ("); + for (int i = 0; i < dashboardSearchTags.emails.size(); i++) { + if (i > 0) query.append(" OR"); + query.append(" rut.account_info_value ILIKE ? OR rut.account_info_value ILIKE ?"); + queryParams.add(dashboardSearchTags.emails.get(i) + "%"); + queryParams.add("%@" + dashboardSearchTags.emails.get(i) + "%"); } - } - - { - // check if we should search through the thirdparty table - if (dashboardSearchTags.shouldThirdPartyTableBeSearched()) { - String QUERY = "SELECT allAuthUsersTable.*" + " FROM " + getConfig(start).getUsersTable() - + " AS allAuthUsersTable" - + " JOIN " + getConfig(start).getThirdPartyUserToTenantTable() - + - " AS thirdPartyToTenantTable ON allAuthUsersTable.app_id = thirdPartyToTenantTable" + - ".app_id AND" - + " allAuthUsersTable.tenant_id = thirdPartyToTenantTable.tenant_id AND" - + " allAuthUsersTable.user_id = thirdPartyToTenantTable.user_id" - + " JOIN " + getConfig(start).getThirdPartyUsersTable() - + " AS thirdPartyTable ON thirdPartyToTenantTable.app_id = thirdPartyTable.app_id AND" - + " thirdPartyToTenantTable.user_id = thirdPartyTable.user_id"; - - // check if email tag is present - if (dashboardSearchTags.emails != null) { - - QUERY += - " WHERE (thirdPartyToTenantTable.app_id = ? AND thirdPartyToTenantTable.tenant_id" + - " = ?)" - + " AND ( thirdPartyTable.email LIKE ? OR thirdPartyTable.email LIKE ?"; - queryList.add(tenantIdentifier.getAppId()); - queryList.add(tenantIdentifier.getTenantId()); - queryList.add(dashboardSearchTags.emails.get(0) + "%"); - queryList.add("%@" + dashboardSearchTags.emails.get(0) + "%"); - - for (int i = 1; i < dashboardSearchTags.emails.size(); i++) { - QUERY += " OR thirdPartyTable.email LIKE ? OR thirdPartyTable.email LIKE ?"; - queryList.add(dashboardSearchTags.emails.get(i) + "%"); - queryList.add("%@" + dashboardSearchTags.emails.get(i) + "%"); - } - - QUERY += " )"; - - } - - // check if providers tag is present - if (dashboardSearchTags.providers != null) { - if (dashboardSearchTags.emails != null) { - QUERY += " AND "; - } else { - QUERY += " WHERE (thirdPartyToTenantTable.app_id = ? AND thirdPartyToTenantTable" + - ".tenant_id = ?) AND "; - queryList.add(tenantIdentifier.getAppId()); - queryList.add(tenantIdentifier.getTenantId()); - } - - QUERY += " ( thirdPartyTable.third_party_id LIKE ?"; - queryList.add(dashboardSearchTags.providers.get(0) + "%"); - for (int i = 1; i < dashboardSearchTags.providers.size(); i++) { - QUERY += " OR thirdPartyTable.third_party_id LIKE ?"; - queryList.add(dashboardSearchTags.providers.get(i) + "%"); - } - - QUERY += " )"; - } - - // check if we need to append this to an existing search query - if (USER_SEARCH_TAG_CONDITION.length() != 0) { - USER_SEARCH_TAG_CONDITION.append(" UNION ").append("SELECT * FROM ( ").append(QUERY) - .append(" LIMIT 1000) AS thirdPartyResultTable"); - - } else { - USER_SEARCH_TAG_CONDITION.append("SELECT * FROM ( ").append(QUERY) - .append(" LIMIT 1000) AS thirdPartyResultTable"); - - } + query.append(")"); + // Phone condition on rut_phone + query.append(" AND rut_phone.account_info_type = 'phone' AND ("); + for (int i = 0; i < dashboardSearchTags.phoneNumbers.size(); i++) { + if (i > 0) query.append(" OR"); + query.append(" rut_phone.account_info_value ILIKE ?"); + queryParams.add(dashboardSearchTags.phoneNumbers.get(i) + "%"); } - } - - { - // check if we should search through the passwordless table - if (dashboardSearchTags.shouldPasswordlessTableBeSearched()) { - String QUERY = "SELECT allAuthUsersTable.*" + " FROM " + getConfig(start).getUsersTable() - + " AS allAuthUsersTable" + - " JOIN " + getConfig(start).getPasswordlessUserToTenantTable() - + " AS passwordlessTable ON allAuthUsersTable.app_id = passwordlessTable.app_id AND" - + " allAuthUsersTable.tenant_id = passwordlessTable.tenant_id AND" - + " allAuthUsersTable.user_id = passwordlessTable.user_id"; - - // check if email tag is present - if (dashboardSearchTags.emails != null) { - - QUERY = QUERY + " WHERE (passwordlessTable.app_id = ? AND passwordlessTable.tenant_id = ?)" - + " AND ( passwordlessTable.email LIKE ? OR passwordlessTable.email LIKE ?"; - queryList.add(tenantIdentifier.getAppId()); - queryList.add(tenantIdentifier.getTenantId()); - queryList.add(dashboardSearchTags.emails.get(0) + "%"); - queryList.add("%@" + dashboardSearchTags.emails.get(0) + "%"); - for (int i = 1; i < dashboardSearchTags.emails.size(); i++) { - QUERY += " OR passwordlessTable.email LIKE ? OR passwordlessTable.email LIKE ?"; - queryList.add(dashboardSearchTags.emails.get(i) + "%"); - queryList.add("%@" + dashboardSearchTags.emails.get(i) + "%"); - } - - QUERY += " )"; - } - - // check if phone tag is present - if (dashboardSearchTags.phoneNumbers != null) { - - if (dashboardSearchTags.emails != null) { - QUERY += " AND "; - } else { - QUERY += " WHERE (passwordlessTable.app_id = ? AND passwordlessTable.tenant_id = ?) " + - "AND "; - queryList.add(tenantIdentifier.getAppId()); - queryList.add(tenantIdentifier.getTenantId()); - } - - QUERY += " ( passwordlessTable.phone_number LIKE ?"; - queryList.add(dashboardSearchTags.phoneNumbers.get(0) + "%"); - for (int i = 1; i < dashboardSearchTags.phoneNumbers.size(); i++) { - QUERY += " OR passwordlessTable.phone_number LIKE ?"; - queryList.add(dashboardSearchTags.phoneNumbers.get(i) + "%"); - } - - QUERY += " )"; + query.append(")"); + // Provider filter (if also present) + if (hasProviders) { + query.append(" AND rut.third_party_id <> '' AND ("); + for (int i = 0; i < dashboardSearchTags.providers.size(); i++) { + if (i > 0) query.append(" OR"); + query.append(" rut.third_party_id ILIKE ?"); + queryParams.add(dashboardSearchTags.providers.get(i) + "%"); } + query.append(")"); + } - // check if we need to append this to an existing search query - if (USER_SEARCH_TAG_CONDITION.length() != 0) { - USER_SEARCH_TAG_CONDITION.append(" UNION ").append("SELECT * FROM ( ").append(QUERY) - .append(" LIMIT 1000) AS passwordlessResultTable"); - - } else { - USER_SEARCH_TAG_CONDITION.append("SELECT * FROM ( ").append(QUERY) - .append(" LIMIT 1000) AS passwordlessResultTable"); - - } + } else if (hasEmails && hasProviders) { + // Email + Provider: single row match (email rows of thirdparty users have third_party_id) + query.append(" AND rut.account_info_type = 'email' AND ("); + for (int i = 0; i < dashboardSearchTags.emails.size(); i++) { + if (i > 0) query.append(" OR"); + query.append(" rut.account_info_value ILIKE ? OR rut.account_info_value ILIKE ?"); + queryParams.add(dashboardSearchTags.emails.get(i) + "%"); + queryParams.add("%@" + dashboardSearchTags.emails.get(i) + "%"); + } + query.append(") AND ("); + for (int i = 0; i < dashboardSearchTags.providers.size(); i++) { + if (i > 0) query.append(" OR"); + query.append(" rut.third_party_id ILIKE ?"); + queryParams.add(dashboardSearchTags.providers.get(i) + "%"); + } + query.append(")"); + + } else if (hasPhones && hasProviders) { + // Phone + Provider: no recipe has both, so this always returns empty + query.append(" AND rut.account_info_type = 'phone' AND ("); + for (int i = 0; i < dashboardSearchTags.phoneNumbers.size(); i++) { + if (i > 0) query.append(" OR"); + query.append(" rut.account_info_value ILIKE ?"); + queryParams.add(dashboardSearchTags.phoneNumbers.get(i) + "%"); + } + query.append(") AND rut.third_party_id <> '' AND ("); + for (int i = 0; i < dashboardSearchTags.providers.size(); i++) { + if (i > 0) query.append(" OR"); + query.append(" rut.third_party_id ILIKE ?"); + queryParams.add(dashboardSearchTags.providers.get(i) + "%"); + } + query.append(")"); + + } else if (hasEmails) { + query.append(" AND rut.account_info_type = 'email' AND ("); + for (int i = 0; i < dashboardSearchTags.emails.size(); i++) { + if (i > 0) query.append(" OR"); + query.append(" rut.account_info_value ILIKE ? OR rut.account_info_value ILIKE ?"); + queryParams.add(dashboardSearchTags.emails.get(i) + "%"); + queryParams.add("%@" + dashboardSearchTags.emails.get(i) + "%"); + } + query.append(")"); + + } else if (hasPhones) { + query.append(" AND rut.account_info_type = 'phone' AND ("); + for (int i = 0; i < dashboardSearchTags.phoneNumbers.size(); i++) { + if (i > 0) query.append(" OR"); + query.append(" rut.account_info_value ILIKE ?"); + queryParams.add(dashboardSearchTags.phoneNumbers.get(i) + "%"); } + query.append(")"); + + } else if (hasProviders) { + query.append(" AND rut.third_party_id <> '' AND ("); + for (int i = 0; i < dashboardSearchTags.providers.size(); i++) { + if (i > 0) query.append(" OR"); + query.append(" rut.third_party_id ILIKE ?"); + queryParams.add(dashboardSearchTags.providers.get(i) + "%"); + } + query.append(")"); } - if (USER_SEARCH_TAG_CONDITION.toString().length() == 0) { - usersFromQuery = new ArrayList<>(); - } else { + query.append(" ORDER BY auid.primary_or_recipe_user_time_joined ").append(timeJoinedOrder) + .append(", auid.primary_or_recipe_user_id DESC LIMIT 1000"); - String finalQuery = - "SELECT DISTINCT primary_or_recipe_user_id, primary_or_recipe_user_time_joined FROM ( " + - USER_SEARCH_TAG_CONDITION.toString() + " )" - + " AS finalResultTable ORDER BY primary_or_recipe_user_time_joined " + - timeJoinedOrder + ", primary_or_recipe_user_id DESC "; - usersFromQuery = execute(start, finalQuery, pst -> { - for (int i = 1; i <= queryList.size(); i++) { - pst.setString(i, queryList.get(i - 1)); - } - }, result -> { - List temp = new ArrayList<>(); - while (result.next()) { - temp.add(result.getString("primary_or_recipe_user_id")); - } - return temp; - }); - } + usersFromQuery = execute(start, query.toString(), pst -> { + for (int i = 0; i < queryParams.size(); i++) { + pst.setString(i + 1, queryParams.get(i)); + } + }, result -> { + List temp = new ArrayList<>(); + while (result.next()) { + temp.add(result.getString("primary_or_recipe_user_id")); + } + return temp; + }); } } else { StringBuilder RECIPE_ID_CONDITION = new StringBuilder(); if (includeRecipeIds != null && includeRecipeIds.length > 0) { - RECIPE_ID_CONDITION.append("recipe_id IN ("); + RECIPE_ID_CONDITION.append("auid.recipe_id IN ("); for (int i = 0; i < includeRecipeIds.length; i++) { - RECIPE_ID_CONDITION.append("?"); if (i != includeRecipeIds.length - 1) { - // not the last element RECIPE_ID_CONDITION.append(","); } } @@ -1298,18 +1269,21 @@ public static AuthRecipeUserInfo[] getUsers(Start start, TenantIdentifier tenant recipeIdCondition = recipeIdCondition + " AND"; } String timeJoinedOrderSymbol = timeJoinedOrder.equals("ASC") ? ">" : "<"; - String QUERY = "SELECT DISTINCT primary_or_recipe_user_id, primary_or_recipe_user_time_joined FROM " + - getConfig(start).getUsersTable() + " WHERE " - + recipeIdCondition + " (primary_or_recipe_user_time_joined " + timeJoinedOrderSymbol - + - " ? OR (primary_or_recipe_user_time_joined = ? AND primary_or_recipe_user_id <= ?)) AND " + - "app_id = ? AND tenant_id = ?" - + " ORDER BY primary_or_recipe_user_time_joined " + timeJoinedOrder - + ", primary_or_recipe_user_id DESC LIMIT ?"; + String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id," + + " auid.primary_or_recipe_user_time_joined" + + " FROM " + getConfig(start).getAppIdToUserIdTable() + " auid" + + " JOIN " + getConfig(start).getRecipeUserTenantsTable() + " rut" + + " ON auid.app_id = rut.app_id AND auid.user_id = rut.recipe_user_id" + + " WHERE " + recipeIdCondition + + " (auid.primary_or_recipe_user_time_joined " + timeJoinedOrderSymbol + + " ? OR (auid.primary_or_recipe_user_time_joined = ?" + + " AND auid.primary_or_recipe_user_id <= ?))" + + " AND auid.app_id = ? AND rut.tenant_id = ?" + + " ORDER BY auid.primary_or_recipe_user_time_joined " + timeJoinedOrder + + ", auid.primary_or_recipe_user_id DESC LIMIT ?"; usersFromQuery = execute(start, QUERY, pst -> { if (includeRecipeIds != null) { for (int i = 0; i < includeRecipeIds.length; i++) { - // i+1 cause this starts with 1 and not 0 pst.setString(i + 1, includeRecipeIds[i].toString()); } } @@ -1329,17 +1303,21 @@ public static AuthRecipeUserInfo[] getUsers(Start start, TenantIdentifier tenant }); } else { String recipeIdCondition = RECIPE_ID_CONDITION.toString(); - String QUERY = "SELECT DISTINCT primary_or_recipe_user_id, primary_or_recipe_user_time_joined FROM " + - getConfig(start).getUsersTable() + " WHERE "; + String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id," + + " auid.primary_or_recipe_user_time_joined" + + " FROM " + getConfig(start).getAppIdToUserIdTable() + " auid" + + " JOIN " + getConfig(start).getRecipeUserTenantsTable() + " rut" + + " ON auid.app_id = rut.app_id AND auid.user_id = rut.recipe_user_id" + + " WHERE "; if (!recipeIdCondition.equals("")) { QUERY += recipeIdCondition + " AND"; } - QUERY += " app_id = ? AND tenant_id = ? ORDER BY primary_or_recipe_user_time_joined " + timeJoinedOrder - + ", primary_or_recipe_user_id DESC LIMIT ?"; + QUERY += " auid.app_id = ? AND rut.tenant_id = ?" + + " ORDER BY auid.primary_or_recipe_user_time_joined " + timeJoinedOrder + + ", auid.primary_or_recipe_user_id DESC LIMIT ?"; usersFromQuery = execute(start, QUERY, pst -> { if (includeRecipeIds != null) { for (int i = 0; i < includeRecipeIds.length; i++) { - // i+1 cause this starts with 1 and not 0 pst.setString(i + 1, includeRecipeIds[i].toString()); } } @@ -1446,8 +1424,6 @@ public static void linkAccounts_Transaction(Start start, Connection sqlCon, AppI }); } - updateTimeJoinedForPrimaryUser_Transaction(start, sqlCon, appIdentifier, primaryUserId); - { String QUERY = "UPDATE " + getConfig(start).getAppIdToUserIdTable() + " SET is_linked_or_is_a_primary_user = true, primary_or_recipe_user_id = (" + @@ -1462,6 +1438,10 @@ public static void linkAccounts_Transaction(Start start, Connection sqlCon, AppI pst.setString(4, recipeUserId); }); } + + // Must be called AFTER both all_auth_recipe_users and app_id_to_user_id have been updated + // with the new primary_or_recipe_user_id, so the MIN(time_joined) subquery sees all linked users. + updateTimeJoinedForPrimaryUser_Transaction(start, sqlCon, appIdentifier, primaryUserId); } public static void linkMultipleAccounts_Transaction(Start start, Connection sqlCon, AppIdentifier appIdentifier, @@ -1512,9 +1492,21 @@ public static void updateTimeJoinedForPrimaryUsers_Transaction(Start start, Conn " SET primary_or_recipe_user_time_joined = (SELECT MIN(time_joined) FROM " + getConfig(start).getUsersTable() + " WHERE app_id = ? AND primary_or_recipe_user_id = ?) WHERE " + " app_id = ? AND primary_or_recipe_user_id = ?"; + String APP_ID_QUERY = "UPDATE " + getConfig(start).getAppIdToUserIdTable() + + " SET primary_or_recipe_user_time_joined = (SELECT MIN(time_joined) FROM " + + getConfig(start).getAppIdToUserIdTable() + " WHERE app_id = ? AND primary_or_recipe_user_id = ?) WHERE " + + " app_id = ? AND primary_or_recipe_user_id = ?"; List usersUpdateBatch = new ArrayList<>(); + List appIdUpdateBatch = new ArrayList<>(); for(String primaryUserId : primaryUserIds) { - usersUpdateBatch.add(pst -> { + PreparedStatementValueSetter setter = pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, primaryUserId); + pst.setString(3, appIdentifier.getAppId()); + pst.setString(4, primaryUserId); + }; + usersUpdateBatch.add(setter); + appIdUpdateBatch.add(pst -> { pst.setString(1, appIdentifier.getAppId()); pst.setString(2, primaryUserId); pst.setString(3, appIdentifier.getAppId()); @@ -1523,6 +1515,7 @@ public static void updateTimeJoinedForPrimaryUsers_Transaction(Start start, Conn } executeBatch(sqlCon, QUERY, usersUpdateBatch); + executeBatch(sqlCon, APP_ID_QUERY, appIdUpdateBatch); } public static void unlinkAccounts_Transaction(Start start, Connection sqlCon, AppIdentifier appIdentifier, @@ -1545,7 +1538,8 @@ public static void unlinkAccounts_Transaction(Start start, Connection sqlCon, Ap { String QUERY = "UPDATE " + getConfig(start).getAppIdToUserIdTable() + - " SET is_linked_or_is_a_primary_user = false, primary_or_recipe_user_id = ?" + + " SET is_linked_or_is_a_primary_user = false, primary_or_recipe_user_id = ?," + + " primary_or_recipe_user_time_joined = time_joined" + " WHERE app_id = ? AND user_id = ?"; update(sqlCon, QUERY, pst -> { @@ -1561,7 +1555,7 @@ public static AuthRecipeUserInfo[] listPrimaryUsersByThirdPartyInfo(Start start, String thirdPartyId, String thirdPartyUserId) throws SQLException, StorageQueryException { - List userIds = ThirdPartyQueries.listUserIdsByThirdPartyInfo(start, appIdentifier, + List userIds = AccountInfoQueries.listPrimaryUserIdsByThirdPartyInfo(start, appIdentifier, thirdPartyId, thirdPartyUserId); List result = getPrimaryUserInfoForUserIds(start, appIdentifier, userIds); @@ -1578,8 +1572,8 @@ public static AuthRecipeUserInfo[] listPrimaryUsersByThirdPartyInfo_Transaction( throws SQLException, StorageQueryException { // Note: Locking is now done at the core level via UserLockingStorage.lockUser() // This method just queries the users without acquiring locks. - List userIds = ThirdPartyQueries.listUserIdsByThirdPartyInfo_Transaction(start, sqlCon, appIdentifier, - thirdPartyId, thirdPartyUserId); + List userIds = AccountInfoQueries.listPrimaryUserIdsByThirdPartyInfo_Transaction(start, sqlCon, + appIdentifier, thirdPartyId, thirdPartyUserId); List result = getPrimaryUserInfoForUserIds_Transaction(start, sqlCon, appIdentifier, userIds); @@ -1592,29 +1586,7 @@ public static AuthRecipeUserInfo[] listPrimaryUsersByThirdPartyInfo_Transaction( public static AuthRecipeUserInfo[] listPrimaryUsersByEmail(Start start, TenantIdentifier tenantIdentifier, String email) throws StorageQueryException, SQLException { - List userIds = new ArrayList<>(); - String emailPasswordUserId = EmailPasswordQueries.getPrimaryUserIdUsingEmail(start, tenantIdentifier, - email); - if (emailPasswordUserId != null) { - userIds.add(emailPasswordUserId); - } - - String passwordlessUserId = PasswordlessQueries.getPrimaryUserIdUsingEmail(start, tenantIdentifier, - email); - if (passwordlessUserId != null) { - userIds.add(passwordlessUserId); - } - - userIds.addAll(ThirdPartyQueries.getPrimaryUserIdUsingEmail(start, tenantIdentifier, email)); - - String webauthnUserId = WebAuthNQueries.getPrimaryUserIdUsingEmail(start, tenantIdentifier, email); - if(webauthnUserId != null) { - userIds.add(webauthnUserId); - } - - // remove duplicates from userIds - Set userIdsSet = new HashSet<>(userIds); - userIds = new ArrayList<>(userIdsSet); + List userIds = AccountInfoQueries.listPrimaryUserIdsByEmail(start, tenantIdentifier, email); List result = getPrimaryUserInfoForUserIds(start, tenantIdentifier.toAppIdentifier(), userIds); @@ -1629,13 +1601,7 @@ public static AuthRecipeUserInfo[] listPrimaryUsersByPhoneNumber(Start start, TenantIdentifier tenantIdentifier, String phoneNumber) throws StorageQueryException, SQLException { - List userIds = new ArrayList<>(); - - String passwordlessUserId = PasswordlessQueries.getPrimaryUserByPhoneNumber(start, tenantIdentifier, - phoneNumber); - if (passwordlessUserId != null) { - userIds.add(passwordlessUserId); - } + List userIds = AccountInfoQueries.listPrimaryUserIdsByPhoneNumber(start, tenantIdentifier, phoneNumber); List result = getPrimaryUserInfoForUserIds(start, tenantIdentifier.toAppIdentifier(), userIds); @@ -1651,7 +1617,7 @@ public static AuthRecipeUserInfo getPrimaryUserByThirdPartyInfo(Start start, String thirdPartyId, String thirdPartyUserId) throws StorageQueryException, SQLException { - String userId = ThirdPartyQueries.getUserIdByThirdPartyInfo(start, tenantIdentifier, + String userId = AccountInfoQueries.getPrimaryUserIdByThirdPartyInfo(start, tenantIdentifier, thirdPartyId, thirdPartyUserId); return getPrimaryUserInfoForUserId(start, tenantIdentifier.toAppIdentifier(), userId); } @@ -1684,7 +1650,7 @@ public static AuthRecipeUserInfo getPrimaryUserByWebauthNCredentialId_Transactio public static String getPrimaryUserIdStrForUserId(Start start, AppIdentifier appIdentifier, String id) throws SQLException, StorageQueryException { - String QUERY = "SELECT primary_or_recipe_user_id FROM " + getConfig(start).getUsersTable() + + String QUERY = "SELECT primary_or_recipe_user_id FROM " + getConfig(start).getAppIdToUserIdTable() + " WHERE user_id = ? AND app_id = ?"; return execute(start, QUERY, pst -> { pst.setString(1, id); @@ -1734,10 +1700,10 @@ private static List getPrimaryUserInfoForUserIds(Start start // column String QUERY = "SELECT au.user_id, au.primary_or_recipe_user_id, au.is_linked_or_is_a_primary_user, au.recipe_id, " + - "aaru.tenant_id, aaru.time_joined FROM " + + "au.time_joined, rt.tenant_id FROM " + getConfig(start).getAppIdToUserIdTable() + " as au " + - "LEFT JOIN " + getConfig(start).getUsersTable() + - " as aaru ON au.app_id = aaru.app_id AND au.user_id = aaru.user_id" + + "LEFT JOIN " + getConfig(start).getRecipeUserTenantsTable() + + " as rt ON au.app_id = rt.app_id AND au.user_id = rt.recipe_user_id" + " WHERE au.primary_or_recipe_user_id IN (SELECT primary_or_recipe_user_id FROM " + getConfig(start).getAppIdToUserIdTable() + " WHERE (user_id IN (" + Utils.generateCommaSeperatedQuestionMarks(userIds.size()) + @@ -1831,10 +1797,10 @@ private static List getPrimaryUserInfoForUserIds_Transaction // column String QUERY = "SELECT au.user_id, au.primary_or_recipe_user_id, au.is_linked_or_is_a_primary_user, au.recipe_id, " + - "aaru.tenant_id, aaru.time_joined " + + "au.time_joined, rt.tenant_id " + "FROM " + getConfig(start).getAppIdToUserIdTable() + " as au" + - " LEFT JOIN " + getConfig(start).getUsersTable() + - " as aaru ON au.app_id = aaru.app_id AND au.user_id = aaru.user_id" + + " LEFT JOIN " + getConfig(start).getRecipeUserTenantsTable() + + " as rt ON au.app_id = rt.app_id AND au.user_id = rt.recipe_user_id" + " WHERE au.primary_or_recipe_user_id IN " + " (SELECT primary_or_recipe_user_id FROM " + getConfig(start).getAppIdToUserIdTable() + @@ -1935,9 +1901,9 @@ public static Map> getTenantIdsForUserIds_transaction(Start String[] userIds) throws SQLException, StorageQueryException { if (userIds != null && userIds.length > 0) { - StringBuilder QUERY = new StringBuilder("SELECT user_id, tenant_id " - + "FROM " + getConfig(start).getUsersTable()); - QUERY.append(" WHERE user_id IN ("); + StringBuilder QUERY = new StringBuilder("SELECT DISTINCT recipe_user_id AS user_id, tenant_id " + + "FROM " + getConfig(start).getRecipeUserTenantsTable()); + QUERY.append(" WHERE recipe_user_id IN ("); for (int i = 0; i < userIds.length; i++) { QUERY.append("?"); @@ -1978,9 +1944,9 @@ public static Map> getTenantIdsForUserIds(Start start, String[] userIds) throws SQLException, StorageQueryException { if (userIds != null && userIds.length > 0) { - StringBuilder QUERY = new StringBuilder("SELECT user_id, tenant_id " - + "FROM " + getConfig(start).getUsersTable()); - QUERY.append(" WHERE user_id IN ("); + StringBuilder QUERY = new StringBuilder("SELECT DISTINCT recipe_user_id AS user_id, tenant_id " + + "FROM " + getConfig(start).getRecipeUserTenantsTable()); + QUERY.append(" WHERE recipe_user_id IN ("); for (int i = 0; i < userIds.length; i++) { QUERY.append("?"); @@ -2107,7 +2073,7 @@ public static int getUsersCountWithMoreThanOneLoginMethodOrTOTPEnabled(Start sta public static boolean checkIfUsesAccountLinking(Start start, AppIdentifier appIdentifier) throws SQLException, StorageQueryException { String QUERY = "SELECT 1 FROM " - + getConfig(start).getUsersTable() + + getConfig(start).getAppIdToUserIdTable() + " WHERE app_id = ? AND is_linked_or_is_a_primary_user = true LIMIT 1"; return execute(start, QUERY, pst -> { @@ -2143,16 +2109,30 @@ public static AccountLinkingInfo getAccountLinkingInfo_Transaction(Start start, public static void updateTimeJoinedForPrimaryUser_Transaction(Start start, Connection sqlCon, AppIdentifier appIdentifier, String primaryUserId) throws SQLException, StorageQueryException { - String QUERY = "UPDATE " + getConfig(start).getUsersTable() + - " SET primary_or_recipe_user_time_joined = (SELECT MIN(time_joined) FROM " + - getConfig(start).getUsersTable() + " WHERE app_id = ? AND primary_or_recipe_user_id = ?) WHERE " + - " app_id = ? AND primary_or_recipe_user_id = ?"; - update(sqlCon, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - pst.setString(2, primaryUserId); - pst.setString(3, appIdentifier.getAppId()); - pst.setString(4, primaryUserId); - }); + { + String QUERY = "UPDATE " + getConfig(start).getUsersTable() + + " SET primary_or_recipe_user_time_joined = (SELECT MIN(time_joined) FROM " + + getConfig(start).getUsersTable() + " WHERE app_id = ? AND primary_or_recipe_user_id = ?) WHERE " + + " app_id = ? AND primary_or_recipe_user_id = ?"; + update(sqlCon, QUERY, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, primaryUserId); + pst.setString(3, appIdentifier.getAppId()); + pst.setString(4, primaryUserId); + }); + } + { + String QUERY = "UPDATE " + getConfig(start).getAppIdToUserIdTable() + + " SET primary_or_recipe_user_time_joined = (SELECT MIN(time_joined) FROM " + + getConfig(start).getAppIdToUserIdTable() + " WHERE app_id = ? AND primary_or_recipe_user_id = ?) WHERE " + + " app_id = ? AND primary_or_recipe_user_id = ?"; + update(sqlCon, QUERY, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, primaryUserId); + pst.setString(3, appIdentifier.getAppId()); + pst.setString(4, primaryUserId); + }); + } } private static class AllAuthRecipeUsersResultHolder { diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/PasswordlessQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/PasswordlessQueries.java index c3fd3fd4..289f8e92 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/PasswordlessQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/PasswordlessQueries.java @@ -69,7 +69,7 @@ public static String getQueryToCreateUsersTable(Start start) { + "CONSTRAINT " + Utils.getConstraintName(schema, usersTable, "user_id", "fkey") + " FOREIGN KEY(app_id, user_id)" + " REFERENCES " + Config.getConfig(start).getAppIdToUserIdTable() + - " (app_id, user_id) ON DELETE CASCADE," + " (app_id, user_id) ON DELETE CASCADE ON UPDATE CASCADE," + "CONSTRAINT " + Utils.getConstraintName(schema, usersTable, null, "pkey") + " PRIMARY KEY (app_id, user_id)" + ");"; @@ -283,8 +283,8 @@ public static void deleteDevicesByPhoneNumber_Transaction(Start start, Connectio String QUERY = "DELETE FROM " + getConfig(start).getPasswordlessDevicesTable() + " WHERE app_id = ? AND phone_number = ? AND tenant_id IN (" - + " SELECT tenant_id FROM " + getConfig(start).getPasswordlessUserToTenantTable() - + " WHERE app_id = ? AND user_id = ?" + + " SELECT tenant_id FROM " + getConfig(start).getRecipeUserTenantsTable() + + " WHERE app_id = ? AND recipe_user_id = ?" + ")"; update(con, QUERY, pst -> { @@ -315,8 +315,8 @@ public static void deleteDevicesByEmail_Transaction(Start start, Connection con, String QUERY = "DELETE FROM " + getConfig(start).getPasswordlessDevicesTable() + " WHERE app_id = ? AND email = ? AND tenant_id IN (" - + " SELECT tenant_id FROM " + getConfig(start).getPasswordlessUserToTenantTable() - + " WHERE app_id = ? AND user_id = ?" + + " SELECT tenant_id FROM " + getConfig(start).getRecipeUserTenantsTable() + + " WHERE app_id = ? AND recipe_user_id = ?" + ")"; update(con, QUERY, pst -> { @@ -427,12 +427,15 @@ public static AuthRecipeUserInfo createUser(Start start, TenantIdentifier tenant try { { // app_id_to_user_id String QUERY = "INSERT INTO " + getConfig(start).getAppIdToUserIdTable() - + "(app_id, user_id, primary_or_recipe_user_id, recipe_id)" + " VALUES(?, ?, ?, ?)"; + + "(app_id, user_id, primary_or_recipe_user_id, recipe_id, time_joined, primary_or_recipe_user_time_joined)" + + " VALUES(?, ?, ?, ?, ?, ?)"; update(sqlCon, QUERY, pst -> { pst.setString(1, tenantIdentifier.getAppId()); pst.setString(2, id); pst.setString(3, id); pst.setString(4, PASSWORDLESS.toString()); + pst.setLong(5, timeJoined); + pst.setLong(6, timeJoined); }); } @@ -454,21 +457,17 @@ public static AuthRecipeUserInfo createUser(Start start, TenantIdentifier tenant } { // recipe_user_tenants - ACCOUNT_INFO_TYPE accountInfoType; - String accountInfoValue; - if (email != null) { - accountInfoType = ACCOUNT_INFO_TYPE.EMAIL; - accountInfoValue = email; - } else if (phoneNumber != null) { - accountInfoType = ACCOUNT_INFO_TYPE.PHONE_NUMBER; - accountInfoValue = phoneNumber; - } else { + AccountInfoQueries.addRecipeUserAccountInfo_Transaction(start, sqlCon, tenantIdentifier, id, + PASSWORDLESS.toString(), ACCOUNT_INFO_TYPE.EMAIL, "", "", email); + } + if (phoneNumber != null) { + AccountInfoQueries.addRecipeUserAccountInfo_Transaction(start, sqlCon, tenantIdentifier, id, + PASSWORDLESS.toString(), ACCOUNT_INFO_TYPE.PHONE_NUMBER, "", "", phoneNumber); + } + if (email == null && phoneNumber == null) { throw new IllegalArgumentException("Either email or phoneNumber must be provided"); } - - AccountInfoQueries.addRecipeUserAccountInfo_Transaction(start, sqlCon, tenantIdentifier, id, - PASSWORDLESS.toString(), accountInfoType, "", "", accountInfoValue); } { // passwordless_users @@ -511,12 +510,12 @@ public static AuthRecipeUserInfo createUser(Start start, TenantIdentifier tenant private static UserInfoWithTenantId[] getUserInfosWithTenant_Transaction(Start start, Connection con, AppIdentifier appIdentifier, String userId) throws StorageQueryException, SQLException { - String QUERY = "SELECT pl_users.user_id as user_id, pl_users.email as email, " - + "pl_users.phone_number as phone_number, pl_users_to_tenant.tenant_id as tenant_id " + String QUERY = "SELECT DISTINCT pl_users.user_id as user_id, pl_users.email as email, " + + "pl_users.phone_number as phone_number, rut.tenant_id as tenant_id " + "FROM " + getConfig(start).getPasswordlessUsersTable() + " AS pl_users " - + "JOIN " + getConfig(start).getPasswordlessUserToTenantTable() + " AS pl_users_to_tenant " - + "ON pl_users.app_id = pl_users_to_tenant.app_id AND pl_users.user_id = pl_users_to_tenant.user_id " - + "WHERE pl_users_to_tenant.app_id = ? AND pl_users_to_tenant.user_id = ?"; + + "JOIN " + getConfig(start).getRecipeUserTenantsTable() + " AS rut " + + "ON pl_users.app_id = rut.app_id AND pl_users.user_id = rut.recipe_user_id " + + "WHERE rut.app_id = ? AND rut.recipe_user_id = ?"; return execute(con, QUERY, pst -> { pst.setString(1, appIdentifier.getAppId()); pst.setString(2, userId); @@ -528,9 +527,8 @@ private static UserInfoWithTenantId[] getUserInfosWithTenant_Transaction(Start s result.getString("user_id"), result.getString("tenant_id"), result.getString("email"), - result.getString("phoneNumber") + result.getString("phone_number") )); - PasswordlessDeviceRowMapper.getInstance().mapOrThrow(result); } return userInfos.toArray(new UserInfoWithTenantId[0]); }); @@ -845,11 +843,12 @@ private static UserInfoPartial getUserById_Transaction(Start start, Connection s public static String getPrimaryUserIdUsingEmail(Start start, TenantIdentifier tenantIdentifier, String email) throws StorageQueryException, SQLException { - String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id " - + "FROM " + getConfig(start).getPasswordlessUserToTenantTable() + " AS pless" + - " JOIN " + getConfig(start).getUsersTable() + " AS all_users" + - " ON pless.app_id = all_users.app_id AND pless.user_id = all_users.user_id" + - " WHERE pless.app_id = ? AND pless.tenant_id = ? AND pless.email = ?"; + String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id AS user_id " + + "FROM " + getConfig(start).getRecipeUserTenantsTable() + " AS rut" + + " JOIN " + getConfig(start).getAppIdToUserIdTable() + " AS auid" + + " ON rut.app_id = auid.app_id AND rut.recipe_user_id = auid.user_id" + + " WHERE rut.app_id = ? AND rut.tenant_id = ? AND rut.account_info_type = 'email'" + + " AND rut.account_info_value = ? AND rut.recipe_id = 'passwordless'"; return execute(start, QUERY, pst -> { pst.setString(1, tenantIdentifier.getAppId()); @@ -866,11 +865,12 @@ public static String getPrimaryUserIdUsingEmail(Start start, TenantIdentifier te public static String getPrimaryUserByPhoneNumber(Start start, TenantIdentifier tenantIdentifier, @Nonnull String phoneNumber) throws StorageQueryException, SQLException { - String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id " - + "FROM " + getConfig(start).getPasswordlessUserToTenantTable() + " AS pless" + - " JOIN " + getConfig(start).getUsersTable() + " AS all_users" + - " ON pless.app_id = all_users.app_id AND pless.user_id = all_users.user_id" + - " WHERE pless.app_id = ? AND pless.tenant_id = ? AND pless.phone_number = ?"; + String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id AS user_id " + + "FROM " + getConfig(start).getRecipeUserTenantsTable() + " AS rut" + + " JOIN " + getConfig(start).getAppIdToUserIdTable() + " AS auid" + + " ON rut.app_id = auid.app_id AND rut.recipe_user_id = auid.user_id" + + " WHERE rut.app_id = ? AND rut.tenant_id = ? AND rut.account_info_type = 'phone'" + + " AND rut.account_info_value = ? AND rut.recipe_id = 'passwordless'"; return execute(start, QUERY, pst -> { pst.setString(1, tenantIdentifier.getAppId()); @@ -1082,7 +1082,8 @@ public static void importUsers_Transaction(Connection sqlCon, Start start, throws SQLException, StorageQueryException { String app_id_to_user_id_QUERY = "INSERT INTO " + getConfig(start).getAppIdToUserIdTable() - + "(app_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id)" + " VALUES(?, ?, ?, ?, ?)"; + + "(app_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id, time_joined, primary_or_recipe_user_time_joined)" + + " VALUES(?, ?, ?, ?, ?, ?, ?)"; String all_auth_recipe_users_QUERY = "INSERT INTO " + getConfig(start).getUsersTable() + "(app_id, tenant_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id, time_joined, " + @@ -1130,6 +1131,8 @@ public static void importUsers_Transaction(Connection sqlCon, Start start, pst.setString(3, primaryOrRecipeUserId); pst.setBoolean(4, isLinkedOrIsPrimaryUser); pst.setString(5, PASSWORDLESS.toString()); + pst.setLong(6, user.timeJoinedMSSinceEpoch); + pst.setLong(7, user.timeJoinedMSSinceEpoch); }); passwordlessUsersBatch.add(pst -> { diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/SessionQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/SessionQueries.java index 00583f6a..b08ee2ae 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/SessionQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/SessionQueries.java @@ -68,6 +68,7 @@ public static String getQueryToCreateSessionInfoTable(Start start) { // @formatter:on } + // TODO: Add IF NOT EXISTS to prevent crash on dirty DB state from prior test failures public static String getQueryToCreateTenantIdIndexForSessionInfoTable(Start start) { return "CREATE INDEX session_info_tenant_id_index ON " + Config.getConfig(start).getSessionInfoTable() + "(app_id, tenant_id);"; @@ -90,11 +91,13 @@ static String getQueryToCreateAccessTokenSigningKeysTable(Start start) { // @formatter:on } + // TODO: Add IF NOT EXISTS to prevent crash on dirty DB state from prior test failures public static String getQueryToCreateAppIdIndexForAccessTokenSigningKeysTable(Start start) { return "CREATE INDEX access_token_signing_keys_app_id_index ON " + Config.getConfig(start).getAccessTokenSigningKeysTable() + "(app_id);"; } + // TODO: Add IF NOT EXISTS to prevent crash on dirty DB state from prior test failures static String getQueryToCreateSessionExpiryIndex(Start start) { return "CREATE INDEX session_expiry_index ON " + Config.getConfig(start).getSessionInfoTable() + "(expires_at);"; @@ -155,7 +158,7 @@ public static SessionInfo getSessionInfo_Transaction(Start start, Connection con "FROM " + getConfig(start).getUserIdMappingTable() + " um2 " + "WHERE um2.app_id = ? AND um2.supertokens_user_id IN (" + "SELECT primary_or_recipe_user_id " + - "FROM " + getConfig(start).getUsersTable() + " " + + "FROM " + getConfig(start).getAppIdToUserIdTable() + " " + "WHERE app_id = ? AND user_id IN (" + "SELECT user_id FROM (" + "SELECT um1.supertokens_user_id as user_id, 0 as o1 " + @@ -169,7 +172,7 @@ public static SessionInfo getSessionInfo_Transaction(Start start, Connection con ") " + "UNION " + "SELECT primary_or_recipe_user_id, 1 as o " + - "FROM " + getConfig(start).getUsersTable() + " " + + "FROM " + getConfig(start).getAppIdToUserIdTable() + " " + "WHERE app_id = ? AND user_id IN (" + "SELECT user_ID FROM (" + "SELECT um1.supertokens_user_id as user_id, 0 as o2 " + @@ -427,7 +430,7 @@ public static SessionInfo getSession(Start start, TenantIdentifier tenantIdentif "sess.created_at_time, sess.jwt_user_payload, sess.use_static_key, users" + ".primary_or_recipe_user_id FROM " + getConfig(start).getSessionInfoTable() - + " AS sess LEFT JOIN " + getConfig(start).getUsersTable() + + + " AS sess LEFT JOIN " + getConfig(start).getAppIdToUserIdTable() + " as users ON sess.app_id = users.app_id AND sess.user_id = users.user_id WHERE sess.app_id =" + " ? AND " + "sess.tenant_id = ? AND sess.session_handle = ?"; diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/ThirdPartyQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/ThirdPartyQueries.java index 121c7f17..00b29ec4 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/ThirdPartyQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/ThirdPartyQueries.java @@ -65,7 +65,7 @@ static String getQueryToCreateUsersTable(Start start) { + "CONSTRAINT " + Utils.getConstraintName(schema, thirdPartyUsersTable, "user_id", "fkey") + " FOREIGN KEY(app_id, user_id)" + " REFERENCES " + Config.getConfig(start).getAppIdToUserIdTable() + - " (app_id, user_id) ON DELETE CASCADE," + " (app_id, user_id) ON DELETE CASCADE ON UPDATE CASCADE," + "CONSTRAINT " + Utils.getConstraintName(schema, thirdPartyUsersTable, null, "pkey") + " PRIMARY KEY (app_id, user_id)" + ");"; @@ -118,12 +118,15 @@ public static AuthRecipeUserInfo signUp(Start start, TenantIdentifier tenantIden try { { // app_id_to_user_id String QUERY = "INSERT INTO " + getConfig(start).getAppIdToUserIdTable() - + "(app_id, user_id, primary_or_recipe_user_id, recipe_id)" + " VALUES(?, ?, ?, ?)"; + + "(app_id, user_id, primary_or_recipe_user_id, recipe_id, time_joined, primary_or_recipe_user_time_joined)" + + " VALUES(?, ?, ?, ?, ?, ?)"; update(sqlCon, QUERY, pst -> { pst.setString(1, tenantIdentifier.getAppId()); pst.setString(2, id); pst.setString(3, id); pst.setString(4, THIRD_PARTY.toString()); + pst.setLong(5, timeJoined); + pst.setLong(6, timeJoined); }); } @@ -375,11 +378,13 @@ public static String getUserIdByThirdPartyInfo(Start start, TenantIdentifier ten String thirdPartyId, String thirdPartyUserId) throws SQLException, StorageQueryException { - String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id " - + "FROM " + getConfig(start).getThirdPartyUserToTenantTable() + " AS tp" + - " JOIN " + getConfig(start).getUsersTable() + " AS all_users" + - " ON tp.app_id = all_users.app_id AND tp.user_id = all_users.user_id" + - " WHERE tp.app_id = ? AND tp.tenant_id = ? AND tp.third_party_id = ? AND tp.third_party_user_id = ?"; + String QUERY = "SELECT DISTINCT a.primary_or_recipe_user_id AS user_id " + + "FROM " + getConfig(start).getRecipeUserTenantsTable() + " AS rut" + + " JOIN " + getConfig(start).getAppIdToUserIdTable() + " AS a" + + " ON rut.app_id = a.app_id AND rut.recipe_user_id = a.user_id" + + " WHERE rut.app_id = ? AND rut.tenant_id = ?" + + " AND rut.account_info_type = 'tparty'" + + " AND rut.third_party_id = ? AND rut.third_party_user_id = ?"; return execute(start, QUERY, pst -> { pst.setString(1, tenantIdentifier.getAppId()); @@ -431,13 +436,13 @@ private static UserInfoPartial getUserInfoUsingUserId_Transaction(Start start, C public static List getPrimaryUserIdUsingEmail(Start start, TenantIdentifier tenantIdentifier, String email) throws StorageQueryException, SQLException { - String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id " - + "FROM " + getConfig(start).getThirdPartyUsersTable() + " AS tp" + - " JOIN " + getConfig(start).getUsersTable() + " AS all_users" + - " ON tp.app_id = all_users.app_id AND tp.user_id = all_users.user_id" + - " JOIN " + getConfig(start).getThirdPartyUserToTenantTable() + " AS tp_tenants" + - " ON tp_tenants.app_id = all_users.app_id AND tp_tenants.user_id = all_users.user_id" + - " WHERE tp.app_id = ? AND tp_tenants.tenant_id = ? AND tp.email = ?"; + String QUERY = "SELECT DISTINCT a.primary_or_recipe_user_id AS user_id " + + "FROM " + getConfig(start).getRecipeUserTenantsTable() + " AS rut" + + " JOIN " + getConfig(start).getAppIdToUserIdTable() + " AS a" + + " ON rut.app_id = a.app_id AND rut.recipe_user_id = a.user_id" + + " WHERE rut.app_id = ? AND rut.tenant_id = ?" + + " AND rut.account_info_type = 'email' AND rut.account_info_value = ?" + + " AND rut.recipe_id = 'thirdparty'"; return execute(start, QUERY, pst -> { pst.setString(1, tenantIdentifier.getAppId()); @@ -452,54 +457,6 @@ public static List getPrimaryUserIdUsingEmail(Start start, }); } - public static List getPrimaryUserIdUsingEmail_Transaction(Start start, Connection con, - AppIdentifier appIdentifier, String email) - throws StorageQueryException, SQLException { - String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id " - + "FROM " + getConfig(start).getThirdPartyUsersTable() + " AS tp" + - " JOIN " + getConfig(start).getAppIdToUserIdTable() + " AS all_users" + - " ON tp.app_id = all_users.app_id AND tp.user_id = all_users.user_id" + - " WHERE tp.app_id = ? AND tp.email = ?"; - - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - pst.setString(2, email); - }, result -> { - List finalResult = new ArrayList<>(); - while (result.next()) { - finalResult.add(result.getString("user_id")); - } - return finalResult; - }); - } - - public static List getPrimaryUserIdsUsingMultipleEmails_Transaction(Start start, Connection con, - AppIdentifier appIdentifier, - List emails) - throws StorageQueryException, SQLException { - if(emails == null || emails.isEmpty()){ - return new ArrayList<>(); - } - String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id " - + "FROM " + getConfig(start).getThirdPartyUsersTable() + " AS ep" + - " JOIN " + getConfig(start).getAppIdToUserIdTable() + " AS all_users" + - " ON ep.app_id = all_users.app_id AND ep.user_id = all_users.user_id" + - " WHERE ep.app_id = ? AND ep.email IN ( " + Utils.generateCommaSeperatedQuestionMarks(emails.size()) + " )"; - - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - for (int i = 0; i < emails.size(); i++) { - pst.setString(2+i, emails.get(i)); - } - }, result -> { - List userIds = new ArrayList<>(); - while (result.next()) { - userIds.add(result.getString("user_id")); - } - return userIds; - }); - } - public static boolean addUserIdToTenant_Transaction(Start start, Connection sqlCon, TenantIdentifier tenantIdentifier, String userId) throws SQLException, StorageQueryException, UnknownUserIdException { @@ -576,7 +533,8 @@ public static void importUser_Transaction(Start start, Connection sqlConnection, throws SQLException, StorageQueryException { String app_id_userid_QUERY = "INSERT INTO " + getConfig(start).getAppIdToUserIdTable() - + "(app_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id)" + " VALUES(?, ?, ?, ?, ?)"; + + "(app_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id, time_joined, primary_or_recipe_user_time_joined)" + + " VALUES(?, ?, ?, ?, ?, ?, ?)"; String all_auth_recipe_users_QUERY = "INSERT INTO " + getConfig(start).getUsersTable() + @@ -619,6 +577,8 @@ public static void importUser_Transaction(Start start, Connection sqlConnection, pst.setString(3, primaryOrRecipeUserId); pst.setBoolean(4, isLinkedOrIsPrimaryUser); pst.setString(5, THIRD_PARTY.toString()); + pst.setLong(6, user.timeJoinedMSSinceEpoch); + pst.setLong(7, user.timeJoinedMSSinceEpoch); }); thirdPartyUsersBatch.add(pst -> { diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/UserIdMappingQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/UserIdMappingQueries.java index 80db7375..9f2ff25a 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/UserIdMappingQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/UserIdMappingQueries.java @@ -58,7 +58,7 @@ public static String getQueryToCreateUserIdMappingTable(Start start) { + "CONSTRAINT " + Utils.getConstraintName(schema, userIdMappingTable, "supertokens_user_id", "fkey") + " FOREIGN KEY (app_id, supertokens_user_id)" + " REFERENCES " + Config.getConfig(start).getAppIdToUserIdTable() + "(app_id, user_id)" + - " ON DELETE CASCADE" + " ON DELETE CASCADE ON UPDATE CASCADE" + ");"; // @formatter:on } diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/UserLockingQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/UserLockingQueries.java index 4717d9b4..7c1e25c2 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/UserLockingQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/UserLockingQueries.java @@ -25,6 +25,8 @@ import io.supertokens.storage.postgresql.Start; import io.supertokens.storage.postgresql.config.Config; +import io.supertokens.storage.postgresql.utils.Utils; + import java.sql.Connection; import java.sql.SQLException; import java.util.*; @@ -33,19 +35,6 @@ public class UserLockingQueries { - /** - * Holds user lock data fetched from app_id_to_user_id table. - */ - private static class UserLockData { - final String primaryOrRecipeUserId; // empty string if not linked/primary, null if not found - final String recipeId; - - UserLockData(String primaryOrRecipeUserId, String recipeId) { - this.primaryOrRecipeUserId = primaryOrRecipeUserId; - this.recipeId = recipeId; - } - } - /** * Locks a single user and returns LockedUser. * Also locks the primary user if the user is linked. @@ -56,7 +45,8 @@ public static LockedUser lockUser(Start start, Connection con, AppIdentifier app } /** - * Locks multiple users with deadlock prevention (consistent ordering). + * Locks multiple users (and their primaries) with a single query. + * Uses ORDER BY user_id to acquire locks in consistent order, preventing deadlocks. */ public static List lockUsers(Start start, Connection con, AppIdentifier appIdentifier, List userIds) @@ -66,46 +56,58 @@ public static List lockUsers(Start start, Connection con, AppIdentif return Collections.emptyList(); } - // Step 1: Read user lock data for all users (without lock) - Map userToLockData = new HashMap<>(); - Set allIdsToLock = new TreeSet<>(); // TreeSet for consistent ordering - - for (String userId : userIds) { - allIdsToLock.add(userId); - UserLockData lockData = readUserLockData(start, con, appIdentifier, userId); - if (lockData == null) { - throw new UserNotFoundForLockingException(userId); + String table = Config.getConfig(start).getAppIdToUserIdTable(); + String placeholders = Utils.generateCommaSeperatedQuestionMarks(userIds.size()); + + // Single query that locks both the requested users AND their primary users (if linked). + // The UNION ensures we lock primaries even if they weren't in the original request. + // ORDER BY ensures consistent lock ordering to prevent deadlocks. + String QUERY = "SELECT u.user_id, u.primary_or_recipe_user_id, u.is_linked_or_is_a_primary_user, u.recipe_id" + + " FROM " + table + " u" + + " WHERE u.app_id = ? AND u.user_id IN (" + + " SELECT user_id FROM " + table + " WHERE app_id = ? AND user_id IN (" + placeholders + ")" + + " UNION" + + " SELECT primary_or_recipe_user_id FROM " + table + + " WHERE app_id = ? AND user_id IN (" + placeholders + ")" + + " AND is_linked_or_is_a_primary_user = TRUE" + + " )" + + " ORDER BY u.user_id" + + " FOR UPDATE"; + + // Build the result map from a single query + Map lockedByUserId = execute(con, QUERY, pst -> { + int idx = 1; + pst.setString(idx++, appIdentifier.getAppId()); + // First subquery params + pst.setString(idx++, appIdentifier.getAppId()); + for (String uid : userIds) { + pst.setString(idx++, uid); } - userToLockData.put(userId, lockData); - // Empty string means user exists but is not primary/linked - don't add as additional lock target - // Non-empty and different from userId means user is linked to a primary - if (!lockData.primaryOrRecipeUserId.isEmpty() && !lockData.primaryOrRecipeUserId.equals(userId)) { - allIdsToLock.add(lockData.primaryOrRecipeUserId); + // Second subquery params + pst.setString(idx++, appIdentifier.getAppId()); + for (String uid : userIds) { + pst.setString(idx++, uid); } - } - - // Step 2: Lock all users in consistent alphabetical order (prevents deadlocks) - for (String id : allIdsToLock) { - lockSingleUser(start, con, appIdentifier, id); - } + }, rs -> { + Map map = new HashMap<>(); + while (rs.next()) { + String uid = rs.getString("user_id").trim(); + String recipeId = rs.getString("recipe_id"); + boolean isLinkedOrPrimary = rs.getBoolean("is_linked_or_is_a_primary_user"); + String primaryUserId = isLinkedOrPrimary ? rs.getString("primary_or_recipe_user_id") : null; + map.put(uid, new LockedUserImpl(uid, recipeId, primaryUserId, con)); + } + return map; + }); - // Step 3: Re-read user data under lock (may have changed) - List result = new ArrayList<>(); + // Build result list in the same order as requested, verifying all users were found + List result = new ArrayList<>(userIds.size()); for (String userId : userIds) { - UserLockData confirmedData = readUserLockData(start, con, appIdentifier, userId); - if (confirmedData == null) { + LockedUser locked = lockedByUserId.get(userId); + if (locked == null) { throw new UserNotFoundForLockingException(userId); } - - // Convert empty string to null for LockedUserImpl (user is not primary or linked) - String primaryUserIdForLock = confirmedData.primaryOrRecipeUserId.isEmpty() ? null : confirmedData.primaryOrRecipeUserId; - - // If primary changed and is not null/empty, we need to lock the new primary too - if (primaryUserIdForLock != null && !allIdsToLock.contains(primaryUserIdForLock)) { - lockSingleUser(start, con, appIdentifier, primaryUserIdForLock); - } - - result.add(new LockedUserImpl(userId, confirmedData.recipeId, primaryUserIdForLock, con)); + result.add(locked); } return result; @@ -121,57 +123,4 @@ public static LockedUserPair lockUsersForLinking(Start start, Connection con, Ap List locked = lockUsers(start, con, appIdentifier, List.of(recipeUserId, primaryUserId)); return new LockedUserPair(locked.get(0), locked.get(1)); } - - /** - * Acquires FOR UPDATE lock on a single user row. - * Uses app_id_to_user_id table because users may not be in all_auth_recipe_users - * if they've been removed from all tenants. - */ - private static void lockSingleUser(Start start, Connection con, AppIdentifier appIdentifier, String userId) - throws SQLException, StorageQueryException, UserNotFoundForLockingException { - - String QUERY = "SELECT user_id FROM " + Config.getConfig(start).getAppIdToUserIdTable() - + " WHERE app_id = ? AND user_id = ? FOR UPDATE"; - - boolean found = execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - pst.setString(2, userId); - }, rs -> rs.next()); - - if (!found) { - throw new UserNotFoundForLockingException(userId); - } - } - - /** - * Reads user lock data (primary_or_recipe_user_id and recipe_id) for a user (without locking). - * Uses app_id_to_user_id table because users may not be in all_auth_recipe_users - * if they've been removed from all tenants. - * Returns null if user doesn't exist. - * Returns UserLockData with empty string primaryOrRecipeUserId if user exists but is not primary or linked. - * Returns UserLockData with the primary_or_recipe_user_id if user is primary or linked. - */ - private static UserLockData readUserLockData(Start start, Connection con, AppIdentifier appIdentifier, String userId) - throws SQLException, StorageQueryException { - - String QUERY = "SELECT primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id FROM " + Config.getConfig(start).getAppIdToUserIdTable() - + " WHERE app_id = ? AND user_id = ?"; - - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - pst.setString(2, userId); - }, rs -> { - if (rs.next()) { - String recipeId = rs.getString("recipe_id"); - boolean isLinkedOrPrimary = rs.getBoolean("is_linked_or_is_a_primary_user"); - if (isLinkedOrPrimary) { - return new UserLockData(rs.getString("primary_or_recipe_user_id"), recipeId); - } else { - // User exists but is not primary or linked - return empty string to distinguish from not found - return new UserLockData("", recipeId); - } - } - return null; - }); - } } diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/UserMetadataQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/UserMetadataQueries.java index ee4cf72c..ed0173c7 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/UserMetadataQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/UserMetadataQueries.java @@ -57,6 +57,7 @@ public static String getQueryToCreateUserMetadataTable(Start start) { // @formatter:on } + // TODO: Add IF NOT EXISTS to prevent crash on dirty DB state from prior test failures public static String getQueryToCreateAppIdIndexForUserMetadataTable(Start start) { return "CREATE INDEX user_metadata_app_id_index ON " + Config.getConfig(start).getUserMetadataTable() + "(app_id);"; diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/UserRolesQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/UserRolesQueries.java index 17e69210..88e08be6 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/UserRolesQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/UserRolesQueries.java @@ -52,6 +52,7 @@ public static String getQueryToCreateRolesTable(Start start) { // @formatter:on } + // TODO: Add IF NOT EXISTS to prevent crash on dirty DB state from prior test failures public static String getQueryToCreateAppIdIndexForRolesTable(Start start) { return "CREATE INDEX roles_app_id_index ON " + getConfig(start).getRolesTable() + "(app_id);"; } @@ -73,11 +74,13 @@ public static String getQueryToCreateRolePermissionsTable(Start start) { // @formatter:on } + // TODO: Add IF NOT EXISTS to prevent crash on dirty DB state from prior test failures public static String getQueryToCreateRoleIndexForRolePermissionsTable(Start start) { return "CREATE INDEX role_permissions_role_index ON " + getConfig(start).getUserRolesPermissionsTable() + "(app_id, role);"; } + // TODO: Add IF NOT EXISTS to prevent crash on dirty DB state from prior test failures static String getQueryToCreateRolePermissionsPermissionIndex(Start start) { return "CREATE INDEX role_permissions_permission_index ON " + getConfig(start).getUserRolesPermissionsTable() + "(app_id, permission);"; diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/WebAuthNQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/WebAuthNQueries.java index b4b766cf..6490bd2d 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/WebAuthNQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/WebAuthNQueries.java @@ -63,7 +63,7 @@ static String getQueryToCreateWebAuthNUsersTable(Start start){ " PRIMARY KEY (app_id, user_id)," + " CONSTRAINT " + Utils.getConstraintName(schema,webAuthNUsersTableName, "user_id", "fkey") + " FOREIGN KEY (app_id, user_id) REFERENCES " + getConfig(start).getAppIdToUserIdTable() + - " (app_id, user_id) ON DELETE CASCADE " + + " (app_id, user_id) ON DELETE CASCADE ON UPDATE CASCADE " + ");"; } @@ -301,12 +301,15 @@ public static void createUser_Transaction(Start start, Connection sqlCon, Tenant try { // app_id_to_user_id String insertAppIdToUserId = "INSERT INTO " + getConfig(start).getAppIdToUserIdTable() - + "(app_id, user_id, primary_or_recipe_user_id, recipe_id)" + " VALUES(?, ?, ?, ?)"; + + "(app_id, user_id, primary_or_recipe_user_id, recipe_id, time_joined, primary_or_recipe_user_time_joined)" + + " VALUES(?, ?, ?, ?, ?, ?)"; update(sqlCon, insertAppIdToUserId, pst -> { pst.setString(1, tenantIdentifier.getAppId()); pst.setString(2, userId); pst.setString(3, userId); pst.setString(4, WEBAUTHN.toString()); + pst.setLong(5, timeJoined); + pst.setLong(6, timeJoined); }); // all_auth_recipe_users @@ -424,17 +427,16 @@ public static String getPrimaryUserIdForTenantUsingEmail_Transaction(Start start TenantIdentifier tenantIdentifier, String email) throws SQLException, StorageQueryException { - String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id " - + "FROM " + getConfig(start).getWebAuthNUserToTenantTable() + " AS webauthn" + - " JOIN " + getConfig(start).getUsersTable() + " AS all_users" + - " ON webauthn.tenant_id = all_users.tenant_id " + - " AND webauthn.app_id = all_users.app_id" + - " AND webauthn.user_id = all_users.user_id" + - " WHERE webauthn.tenant_id = ? AND webauthn.app_id = ? AND webauthn.email = ?"; + String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id AS user_id " + + "FROM " + getConfig(start).getRecipeUserTenantsTable() + " AS rut" + + " JOIN " + getConfig(start).getAppIdToUserIdTable() + " AS auid" + + " ON rut.app_id = auid.app_id AND rut.recipe_user_id = auid.user_id" + + " WHERE rut.app_id = ? AND rut.tenant_id = ? AND rut.account_info_type = 'email'" + + " AND rut.account_info_value = ? AND rut.recipe_id = 'webauthn'"; return execute(sqlConnection, QUERY, pst -> { - pst.setString(1, tenantIdentifier.getTenantId()); - pst.setString(2, tenantIdentifier.getAppId()); + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, tenantIdentifier.getTenantId()); pst.setString(3, email); }, result -> { if (result.next()) { @@ -447,11 +449,12 @@ public static String getPrimaryUserIdForTenantUsingEmail_Transaction(Start start public static String getPrimaryUserIdForAppUsingEmail_Transaction(Start start, Connection sqlConnection, AppIdentifier appIdentifier, String email) throws SQLException, StorageQueryException { - String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id " + - " FROM " + getConfig(start).getWebAuthNUserToTenantTable() + " AS webauthn" + - " JOIN " + getConfig(start).getUsersTable() + " AS all_users" + - " ON webauthn.user_id = all_users.user_id" + - " WHERE webauthn.app_id = ? AND webauthn.email = ?"; + String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id AS user_id " + + "FROM " + getConfig(start).getRecipeUserAccountInfosTable() + " AS ruai" + + " JOIN " + getConfig(start).getAppIdToUserIdTable() + " AS auid" + + " ON ruai.app_id = auid.app_id AND ruai.recipe_user_id = auid.user_id" + + " WHERE ruai.app_id = ? AND ruai.account_info_type = 'email'" + + " AND ruai.account_info_value = ? AND ruai.recipe_id = 'webauthn'"; return execute(sqlConnection, QUERY, pst -> { pst.setString(1, appIdentifier.getAppId()); @@ -470,11 +473,13 @@ public static List getPrimaryUserIdsUsingEmails_Transaction(Start start, if(emails == null || emails.isEmpty()) { return new ArrayList<>(); } - String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id " - + "FROM " + getConfig(start).getWebAuthNUserToTenantTable() + " AS ep" + - " JOIN " + getConfig(start).getUsersTable() + " AS all_users" + - " ON ep.app_id = all_users.app_id AND ep.user_id = all_users.user_id" + - " WHERE ep.app_id = ? AND ep.email in (" + Utils.generateCommaSeperatedQuestionMarks(emails.size()) + ")"; + String QUERY = "SELECT DISTINCT auid.primary_or_recipe_user_id AS user_id " + + "FROM " + getConfig(start).getRecipeUserAccountInfosTable() + " AS ruai" + + " JOIN " + getConfig(start).getAppIdToUserIdTable() + " AS auid" + + " ON ruai.app_id = auid.app_id AND ruai.recipe_user_id = auid.user_id" + + " WHERE ruai.app_id = ? AND ruai.account_info_type = 'email'" + + " AND ruai.account_info_value IN (" + Utils.generateCommaSeperatedQuestionMarks(emails.size()) + ")" + + " AND ruai.recipe_id = 'webauthn'"; return execute(sqlConnection, QUERY, pst -> { pst.setString(1, appIdentifier.getAppId()); diff --git a/src/test/java/io/supertokens/storage/postgresql/test/AccountLinkingTests.java b/src/test/java/io/supertokens/storage/postgresql/test/AccountLinkingTests.java index 5ab09431..3668f325 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/AccountLinkingTests.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/AccountLinkingTests.java @@ -113,9 +113,9 @@ public void canLinkFailsIfTryingToLinkUsersAcrossDifferentStorageLayers() throws } catch (HttpResponseException e) { assert (e.statusCode == 400); assert (e.getMessage() - .equals("Http error. Status Code: 400. Message: Cannot link users that are parts of different " + - "databases. Different pool IDs: |localhost|5432|supertokens|public AND " + - "|localhost|5432|st2|public")); + .matches("Http error. Status Code: 400. Message: Cannot link users that are parts of different " + + "databases. Different pool IDs: \\|[^|]+\\|\\d+\\|[^|]+\\|public AND " + + "\\|[^|]+\\|\\d+\\|[^|]+\\|public")); } diff --git a/src/test/java/io/supertokens/storage/postgresql/test/ConfigTest.java b/src/test/java/io/supertokens/storage/postgresql/test/ConfigTest.java index c572f329..68589118 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/ConfigTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/ConfigTest.java @@ -153,18 +153,29 @@ public void testCustomLocationForConfigLoadsCorrectly() throws Exception { process.kill(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); - // absolute path - File f = new File("../temp/config.yaml"); - args = new String[]{"../", "configFile=" + f.getAbsolutePath()}; + // absolute path - need to use a config file with the correct test database settings + // Copy the worker config (which has test-specific database) to a temp location for custom config test + String workerId = System.getProperty("org.gradle.test.worker"); + File workerConfig = new File("../config" + workerId + ".yaml"); + File customConfig = new File("../temp/config_custom_test.yaml"); + java.nio.file.Files.copy(workerConfig.toPath(), customConfig.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); - process = TestingProcessManager.start(args); - assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + try { + args = new String[]{"../", "configFile=" + customConfig.getAbsolutePath()}; - PostgreSQLConfig config = Config.getConfig((Start) StorageLayer.getStorage(process.getProcess())); - checkConfig(config); + process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); - process.kill(); - assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + PostgreSQLConfig config = Config.getConfig((Start) StorageLayer.getStorage(process.getProcess())); + checkConfig(config); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } finally { + // Clean up the temporary config file + customConfig.delete(); + } } @Test @@ -339,10 +350,17 @@ public void testAddingSchemaWorks() throws Exception { @Test public void testAddingSchemaViaConnectionUriWorks() throws Exception { + final ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + String workerId = System.getProperty("org.gradle.test.worker"); + PostgreSQLConfig userConfig = mapper.readValue(new File("../config" + workerId + ".yaml"), PostgreSQLConfig.class); + userConfig.validateAndNormalise(); + String hostname = userConfig.getHostName(); + String dbName = userConfig.getDatabaseName(); + String[] args = {"../"}; Utils.setValueInConfig("postgresql_connection_uri", - "postgresql://root:root@localhost:5432/supertokens?currentSchema=myschema"); + "postgresql://root:root@" + hostname + ":5432/" + dbName + "?currentSchema=myschema"); Utils.setValueInConfig("postgresql_table_names_prefix", "some_prefix"); TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); @@ -383,10 +401,17 @@ public void testAddingSchemaViaConnectionUriWorks() throws Exception { @Test public void testAddingSchemaViaConnectionUriWorks2() throws Exception { + final ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + String workerId = System.getProperty("org.gradle.test.worker"); + PostgreSQLConfig userConfig = mapper.readValue(new File("../config" + workerId + ".yaml"), PostgreSQLConfig.class); + userConfig.validateAndNormalise(); + String hostname = userConfig.getHostName(); + String dbName = userConfig.getDatabaseName(); + String[] args = {"../"}; Utils.setValueInConfig("postgresql_connection_uri", - "postgresql://root:root@localhost:5432/supertokens?a=b¤tSchema=myschema"); + "postgresql://root:root@" + hostname + ":5432/" + dbName + "?a=b¤tSchema=myschema"); Utils.setValueInConfig("postgresql_table_names_prefix", "some_prefix"); TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); @@ -427,10 +452,17 @@ public void testAddingSchemaViaConnectionUriWorks2() throws Exception { @Test public void testAddingSchemaViaConnectionUriWorks3() throws Exception { + final ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + String workerId = System.getProperty("org.gradle.test.worker"); + PostgreSQLConfig userConfig = mapper.readValue(new File("../config" + workerId + ".yaml"), PostgreSQLConfig.class); + userConfig.validateAndNormalise(); + String hostname = userConfig.getHostName(); + String dbName = userConfig.getDatabaseName(); + String[] args = {"../"}; Utils.setValueInConfig("postgresql_connection_uri", - "postgresql://root:root@localhost:5432/supertokens?e=f¤tSchema=myschema&a=b&c=d"); + "postgresql://root:root@" + hostname + ":5432/" + dbName + "?e=f¤tSchema=myschema&a=b&c=d"); Utils.setValueInConfig("postgresql_table_names_prefix", "some_prefix"); TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); @@ -477,11 +509,12 @@ public void testValidConnectionURI() throws Exception { userConfig.validateAndNormalise(); String hostname = userConfig.getHostName(); + String dbName = userConfig.getDatabaseName(); { String[] args = {"../"}; Utils.setValueInConfig("postgresql_connection_uri", - "postgresql://root:root@" + hostname + ":5432/supertokens"); + "postgresql://root:root@" + hostname + ":5432/" + dbName); Utils.commentConfigValue("postgresql_password"); Utils.commentConfigValue("postgresql_user"); Utils.commentConfigValue("postgresql_port"); @@ -501,7 +534,7 @@ public void testValidConnectionURI() throws Exception { Utils.reset(); String[] args = {"../"}; - Utils.setValueInConfig("postgresql_connection_uri", "postgresql://root:root@" + hostname + "/supertokens"); + Utils.setValueInConfig("postgresql_connection_uri", "postgresql://root:root@" + hostname + "/" + dbName); Utils.commentConfigValue("postgresql_password"); Utils.commentConfigValue("postgresql_user"); Utils.commentConfigValue("postgresql_port"); @@ -521,7 +554,7 @@ public void testValidConnectionURI() throws Exception { Utils.reset(); String[] args = {"../"}; - Utils.setValueInConfig("postgresql_connection_uri", "postgresql://" + hostname + ":5432/supertokens"); + Utils.setValueInConfig("postgresql_connection_uri", "postgresql://" + hostname + ":5432/" + dbName); Utils.commentConfigValue("postgresql_port"); Utils.commentConfigValue("postgresql_host"); Utils.commentConfigValue("postgresql_database_name"); @@ -539,7 +572,7 @@ public void testValidConnectionURI() throws Exception { Utils.reset(); String[] args = {"../"}; - Utils.setValueInConfig("postgresql_connection_uri", "postgresql://root@" + hostname + ":5432/supertokens"); + Utils.setValueInConfig("postgresql_connection_uri", "postgresql://root@" + hostname + ":5432/" + dbName); Utils.commentConfigValue("postgresql_user"); Utils.commentConfigValue("postgresql_port"); Utils.commentConfigValue("postgresql_host"); @@ -556,14 +589,22 @@ public void testValidConnectionURI() throws Exception { { Utils.reset(); + // Re-read config after reset to get the new test database name + PostgreSQLConfig userConfig2 = mapper.readValue(new File("../config" + workerId + ".yaml"), PostgreSQLConfig.class); + userConfig2.validateAndNormalise(); + String dbName2 = userConfig2.getDatabaseName(); + String[] args = {"../"}; + // Use URI without database name, but keep postgresql_database_name in config + // so the test uses the isolated test database Utils.setValueInConfig("postgresql_connection_uri", "postgresql://root:root@" + hostname + ":5432"); Utils.commentConfigValue("postgresql_password"); Utils.commentConfigValue("postgresql_user"); Utils.commentConfigValue("postgresql_port"); Utils.commentConfigValue("postgresql_host"); - Utils.commentConfigValue("postgresql_database_name"); + // Keep postgresql_database_name set so it uses the test database + Utils.setValueInConfig("postgresql_database_name", "\"" + dbName2 + "\""); TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); @@ -583,10 +624,11 @@ public void testInvalidConnectionURI() throws Exception { userConfig.validateAndNormalise(); String hostname = userConfig.getHostName(); + String dbName = userConfig.getDatabaseName(); { String[] args = {"../"}; - Utils.setValueInConfig("postgresql_connection_uri", ":/localhost:5432/supertokens"); + Utils.setValueInConfig("postgresql_connection_uri", ":/localhost:5432/" + dbName); TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); ProcessState.EventAndException e = process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.INIT_FAILURE); @@ -605,7 +647,7 @@ public void testInvalidConnectionURI() throws Exception { String[] args = {"../"}; Utils.setValueInConfig("postgresql_connection_uri", - "postgresql://root:wrongPassword@" + hostname + ":5432/supertokens"); + "postgresql://root:wrongPassword@" + hostname + ":5432/" + dbName); Utils.commentConfigValue("postgresql_password"); Utils.commentConfigValue("postgresql_user"); Utils.commentConfigValue("postgresql_port"); @@ -630,11 +672,12 @@ public void testValidConnectionURIAttributes() throws Exception { PostgreSQLConfig userConfig = mapper.readValue(new File("../config" + workerId + ".yaml"), PostgreSQLConfig.class); userConfig.validateAndNormalise(); String hostname = userConfig.getHostName(); + String dbName = userConfig.getDatabaseName(); { String[] args = {"../"}; Utils.setValueInConfig("postgresql_connection_uri", - "postgresql://root:root@" + hostname + ":5432/supertokens?key1=value1"); + "postgresql://root:root@" + hostname + ":5432/" + dbName + "?key1=value1"); TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); @@ -650,7 +693,7 @@ public void testValidConnectionURIAttributes() throws Exception { String[] args = {"../"}; Utils.setValueInConfig("postgresql_connection_uri", "postgresql://root:root@" + hostname - + ":5432/supertokens?key1=value1&allowPublicKeyRetrieval=false&key2" + "=value2"); + + ":5432/" + dbName + "?key1=value1&allowPublicKeyRetrieval=false&key2" + "=value2"); TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); @@ -690,7 +733,7 @@ public static void checkConfig(PostgreSQLConfig config) throws IOException, Inva "allowPublicKeyRetrieval=true"); assertEquals("Config getSchema did not match default", config.getConnectionScheme(), "postgresql"); assertEquals("Config connectionPoolSize did not match default", config.getConnectionPoolSize(), 10); - assertEquals("Config databaseName does not match default", config.getDatabaseName(), "supertokens"); + assertEquals("Config databaseName does not match default", config.getDatabaseName(), userConfig.getDatabaseName()); assertEquals("Config keyValue table does not match default", config.getKeyValueTable(), "key_value"); assertEquals("Config hostName does not match default ", config.getHostName(), hostname); assertEquals("Config port does not match default", config.getPort(), 5432); diff --git a/src/test/java/io/supertokens/storage/postgresql/test/DatabaseTestHelper.java b/src/test/java/io/supertokens/storage/postgresql/test/DatabaseTestHelper.java new file mode 100644 index 00000000..91013993 --- /dev/null +++ b/src/test/java/io/supertokens/storage/postgresql/test/DatabaseTestHelper.java @@ -0,0 +1,477 @@ +/* + * Copyright (c) 2020, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * 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 io.supertokens.storage.postgresql.test; + +import java.io.File; +import java.io.FileWriter; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Helper class for managing test-specific PostgreSQL databases. + * Each worker gets its own isolated database to prevent interference during parallel execution. + * Within a worker, tests share the same database (data is cleared between tests via TRUNCATE). + */ +public class DatabaseTestHelper { + + private static final AtomicInteger testCounter = new AtomicInteger(0); + + // Thread-local storage for the current test's database name + private static final ThreadLocal currentTestDatabase = new ThreadLocal<>(); + + // Static storage for per-worker database (created once per worker, reused across tests) + private static volatile String workerDatabase = null; + private static volatile boolean workerDatabaseInitialized = false; + private static final Object workerDbLock = new Object(); + + // PostgreSQL connection details - read from environment or use defaults + private static final String PG_HOST = getConfigValue("TEST_PG_HOST", "pg"); + private static final String PG_PORT = getConfigValue("TEST_PG_PORT", "5432"); + private static final String PG_USER = getConfigValue("TEST_PG_USER", "root"); + private static final String PG_PASSWORD = getConfigValue("TEST_PG_PASSWORD", "root"); + private static final String PG_ADMIN_DATABASE = "postgres"; // Database to connect to for admin operations + + private static String getConfigValue(String envName, String defaultValue) { + String value = System.getenv(envName); + if (value == null || value.isEmpty()) { + value = System.getProperty(envName); + } + return (value != null && !value.isEmpty()) ? value : defaultValue; + } + + /** + * Generate a unique database name for the current test. + * Format: test_w{workerId}_t{timestamp}_{counter} + */ + public static String generateTestDatabaseName() { + String workerId = System.getProperty("org.gradle.test.worker", "0"); + long timestamp = System.currentTimeMillis(); + int counter = testCounter.incrementAndGet(); + + // PostgreSQL database names must be lowercase and can't start with a number + // Max length is 63 characters + String dbName = String.format("test_w%s_%d_%d", workerId, timestamp % 1000000, counter); + return dbName.toLowerCase(); + } + + /** + * Get or create the test database for this worker. + * The database is created once per worker and reused across tests. + * Data is cleared between tests via truncateAllData(). + */ + public static String createTestDatabase() { + // Check if we already have a database for this worker + synchronized (workerDbLock) { + if (workerDatabaseInitialized && workerDatabase != null) { + currentTestDatabase.set(workerDatabase); + return workerDatabase; + } + } + + String dbName = generateTestDatabaseName(); + + try { + Class.forName("org.postgresql.Driver"); + } catch (ClassNotFoundException e) { + throw new RuntimeException("PostgreSQL driver not found", e); + } + + String adminUrl = String.format("jdbc:postgresql://%s:%s/%s", PG_HOST, PG_PORT, PG_ADMIN_DATABASE); + + try (Connection conn = DriverManager.getConnection(adminUrl, PG_USER, PG_PASSWORD); + Statement stmt = conn.createStatement()) { + + // Create the database + stmt.executeUpdate("CREATE DATABASE " + dbName); + + // Store as the worker's database (created once, reused for all tests) + synchronized (workerDbLock) { + workerDatabase = dbName; + workerDatabaseInitialized = true; + } + currentTestDatabase.set(dbName); + return dbName; + + } catch (SQLException e) { + System.err.println("[DatabaseTestHelper] Failed to create database " + dbName + ": " + e.getMessage()); + throw new RuntimeException("Failed to create test database: " + dbName, e); + } + } + + /** + * Clear the current test database reference. + * The database persists and is reused for subsequent tests in this worker. + */ + public static void dropCurrentTestDatabase() { + // Don't drop - just clear the thread-local reference + // The database is reused across tests in this worker + currentTestDatabase.remove(); + } + + /** + * Drop a specific test database. + */ + public static void dropTestDatabase(String dbName) { + if (dbName == null || dbName.isEmpty()) { + return; + } + + // Don't drop the admin database or any non-test databases + if (!dbName.startsWith("test_")) { + System.err.println("[DatabaseTestHelper] Refusing to drop non-test database: " + dbName); + return; + } + + String adminUrl = String.format("jdbc:postgresql://%s:%s/%s", PG_HOST, PG_PORT, PG_ADMIN_DATABASE); + + try (Connection conn = DriverManager.getConnection(adminUrl, PG_USER, PG_PASSWORD); + Statement stmt = conn.createStatement()) { + + // Terminate all connections to the database first + stmt.executeUpdate( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '" + dbName + "'" + ); + + // Drop the database + stmt.executeUpdate("DROP DATABASE IF EXISTS " + dbName); + // System.out.println("[DatabaseTestHelper] Dropped test database: " + dbName); + + } catch (SQLException e) { + // Log but don't fail - database might already be dropped + System.err.println("[DatabaseTestHelper] Warning: Could not drop database " + dbName + ": " + e.getMessage()); + } + } + + /** + * Truncate all data in the current test database while keeping tables intact. + * Uses a DO block to find all tables in the public schema and truncate them in a single statement. + * This is much faster than DROP DATABASE + CREATE DATABASE + CREATE TABLE because: + * - No DDL overhead (tables, indexes, constraints all preserved) + * - Next process startup's CREATE TABLE IF NOT EXISTS are all no-ops + * - No need to start a whole SuperTokens process just to drop tables + */ + public static void truncateAllData() { + String dbName = currentTestDatabase.get(); + if (dbName == null) return; + + String dbUrl = String.format("jdbc:postgresql://%s:%s/%s", PG_HOST, PG_PORT, dbName); + + try (Connection conn = DriverManager.getConnection(dbUrl, PG_USER, PG_PASSWORD); + Statement stmt = conn.createStatement()) { + + // Build a single TRUNCATE statement for all tables in the public schema. + // This handles tables with any prefix (default or custom from previous tests). + stmt.execute( + "DO $$ DECLARE tbl_list TEXT; BEGIN " + + "SELECT string_agg(quote_ident(tablename), ', ') INTO tbl_list " + + "FROM pg_tables WHERE schemaname = 'public'; " + + "IF tbl_list IS NOT NULL THEN " + + "EXECUTE 'TRUNCATE TABLE ' || tbl_list || ' CASCADE'; " + + "END IF; END $$" + ); + + } catch (SQLException e) { + System.err.println("[DatabaseTestHelper] Warning: Could not truncate data in " + dbName + ": " + e.getMessage()); + } + } + + /** + * Get the current test database name. + */ + public static String getCurrentTestDatabase() { + return currentTestDatabase.get(); + } + + /** + * Set the current test database name (for cases where it's set externally). + */ + public static void setCurrentTestDatabase(String dbName) { + currentTestDatabase.set(dbName); + } + + /** + * Clean up any stale test databases that might have been left from previous runs. + * This can be called at the start of a test run. + */ + public static void cleanupStaleTestDatabases() { + String adminUrl = String.format("jdbc:postgresql://%s:%s/%s", PG_HOST, PG_PORT, PG_ADMIN_DATABASE); + + try (Connection conn = DriverManager.getConnection(adminUrl, PG_USER, PG_PASSWORD); + Statement stmt = conn.createStatement()) { + + // Find all test databases + var rs = stmt.executeQuery( + "SELECT datname FROM pg_database WHERE datname LIKE 'test_%'" + ); + + while (rs.next()) { + String dbName = rs.getString(1); + // System.out.println("[DatabaseTestHelper] Cleaning up stale database: " + dbName); + dropTestDatabase(dbName); + } + + } catch (SQLException e) { + System.err.println("[DatabaseTestHelper] Warning: Could not cleanup stale databases: " + e.getMessage()); + } + } + + /** + * Get the JDBC URL for the current test database. + */ + public static String getTestDatabaseUrl() { + String dbName = currentTestDatabase.get(); + if (dbName == null) { + throw new IllegalStateException("No test database has been created. Call createTestDatabase() first."); + } + return String.format("jdbc:postgresql://%s:%s/%s", PG_HOST, PG_PORT, dbName); + } + + /** + * Get the PostgreSQL host. + */ + public static String getHost() { + return PG_HOST; + } + + /** + * Get the PostgreSQL port. + */ + public static String getPort() { + return PG_PORT; + } + + /** + * Get the PostgreSQL user. + */ + public static String getUser() { + return PG_USER; + } + + /** + * Get the PostgreSQL password. + */ + public static String getPassword() { + return PG_PASSWORD; + } + + /** + * Take a snapshot of pg_stat_monitor numeric counters for a given database. + * Returns a map of queryid -> (column_name -> cumulative_value), aggregated across buckets. + * Used as the "before" baseline so that collectPgStatMonitorData can compute per-test deltas. + */ + public static Map> takePgStatMonitorSnapshot(String datname) { + Map> snapshot = new HashMap<>(); + if (datname == null || datname.isEmpty()) return snapshot; + + String collectEnv = System.getenv("COLLECT_PG_STAT_MONITOR"); + if (!"true".equalsIgnoreCase(collectEnv)) return snapshot; + + String adminUrl = String.format("jdbc:postgresql://%s:%s/%s", PG_HOST, PG_PORT, PG_ADMIN_DATABASE); + + try (Connection conn = DriverManager.getConnection(adminUrl, PG_USER, PG_PASSWORD); + PreparedStatement pstmt = conn.prepareStatement( + "SELECT * FROM pg_stat_monitor WHERE datname = ?")) { + + pstmt.setString(1, datname); + ResultSet rs = pstmt.executeQuery(); + ResultSetMetaData meta = rs.getMetaData(); + int columnCount = meta.getColumnCount(); + + int queryidCol = findColumnIndex(meta, columnCount, "queryid"); + + while (rs.next()) { + String queryid = queryidCol > 0 + ? String.valueOf(rs.getObject(queryidCol)) : "row_" + rs.getRow(); + Map row = snapshot.computeIfAbsent(queryid, k -> new HashMap<>()); + for (int i = 1; i <= columnCount; i++) { + Object value = rs.getObject(i); + if (value instanceof Number) { + row.merge(meta.getColumnName(i), ((Number) value).doubleValue(), Double::sum); + } + } + } + + } catch (Exception e) { + System.err.println( + "[PgStatMonitor] Warning: Could not take snapshot for " + datname + ": " + + e.getMessage()); + } + return snapshot; + } + + /** + * Collect pg_stat_monitor data for a given database and write per-test delta to a JSON file. + * Only runs if COLLECT_PG_STAT_MONITOR environment variable is set to "true". + * Connects to the "postgres" database where pg_stat_monitor extension is installed. + * + * @param beforeSnapshot snapshot taken before the test started (from takePgStatMonitorSnapshot) + */ + public static void collectPgStatMonitorData(String datname, String testName, + Map> beforeSnapshot) { + if (datname == null || datname.isEmpty()) return; + + String collectEnv = System.getenv("COLLECT_PG_STAT_MONITOR"); + if (!"true".equalsIgnoreCase(collectEnv)) return; + + if (beforeSnapshot == null) beforeSnapshot = Collections.emptyMap(); + + String adminUrl = String.format("jdbc:postgresql://%s:%s/%s", PG_HOST, PG_PORT, PG_ADMIN_DATABASE); + + try (Connection conn = DriverManager.getConnection(adminUrl, PG_USER, PG_PASSWORD); + PreparedStatement pstmt = conn.prepareStatement( + "SELECT * FROM pg_stat_monitor WHERE datname = ?")) { + + pstmt.setString(1, datname); + ResultSet rs = pstmt.executeQuery(); + ResultSetMetaData meta = rs.getMetaData(); + int columnCount = meta.getColumnCount(); + int queryidCol = findColumnIndex(meta, columnCount, "queryid"); + + // Aggregate rows by queryid (across time buckets) + Map> afterNumeric = new LinkedHashMap<>(); + Map> afterStrings = new LinkedHashMap<>(); + + while (rs.next()) { + String queryid = queryidCol > 0 + ? String.valueOf(rs.getObject(queryidCol)) : "row_" + rs.getRow(); + Map numRow = afterNumeric.computeIfAbsent( + queryid, k -> new LinkedHashMap<>()); + Map strRow = afterStrings.computeIfAbsent( + queryid, k -> new LinkedHashMap<>()); + for (int i = 1; i <= columnCount; i++) { + String colName = meta.getColumnName(i); + Object value = rs.getObject(i); + if (value instanceof Number) { + numRow.merge(colName, ((Number) value).doubleValue(), Double::sum); + } else if (!strRow.containsKey(colName)) { + strRow.put(colName, value != null ? value.toString() : null); + } + } + } + + // Build JSON with delta values + StringBuilder json = new StringBuilder(); + json.append("[\n"); + boolean firstRow = true; + int rowCount = 0; + + for (String queryid : afterNumeric.keySet()) { + Map afterVals = afterNumeric.get(queryid); + Map beforeVals = beforeSnapshot.getOrDefault( + queryid, Collections.emptyMap()); + + // Skip queries with no new activity + boolean hasActivity = false; + for (Map.Entry col : afterVals.entrySet()) { + if (col.getValue() - beforeVals.getOrDefault(col.getKey(), 0.0) > 0) { + hasActivity = true; + break; + } + } + if (!hasActivity) continue; + + if (!firstRow) json.append(",\n"); + firstRow = false; + rowCount++; + + json.append(" {"); + boolean firstCol = true; + + // Non-numeric columns (query text, datname, etc.) + for (Map.Entry col : afterStrings.getOrDefault( + queryid, Collections.emptyMap()).entrySet()) { + if (!firstCol) json.append(", "); + firstCol = false; + json.append("\"").append(escapeJsonString(col.getKey())).append("\": "); + if (col.getValue() == null) { + json.append("null"); + } else { + json.append("\"").append(escapeJsonString(col.getValue())).append("\""); + } + } + + // Numeric columns as deltas + for (Map.Entry col : afterVals.entrySet()) { + if (!firstCol) json.append(", "); + firstCol = false; + double delta = col.getValue() - beforeVals.getOrDefault(col.getKey(), 0.0); + json.append("\"").append(escapeJsonString(col.getKey())).append("\": "); + if (delta == Math.floor(delta) && !Double.isInfinite(delta)) { + json.append((long) delta); + } else { + json.append(delta); + } + } + json.append("}"); + } + json.append("\n]"); + + // Write to file + String outputDir = System.getenv("PG_STAT_MONITOR_OUTPUT_DIR"); + if (outputDir == null || outputDir.isEmpty()) { + outputDir = "pg_stat_monitor_output"; + } + File dir = new File(outputDir); + dir.mkdirs(); + + String safeName = (testName != null ? testName : "unknown") + .replaceAll("[^a-zA-Z0-9._-]", "_"); + String filename = datname + "_" + safeName + "_" + System.currentTimeMillis() + ".json"; + File outputFile = new File(dir, filename); + try (FileWriter fw = new FileWriter(outputFile)) { + fw.write(json.toString()); + } + + System.out.println( + "[PgStatMonitor] Collected " + rowCount + " query stats for " + datname + + " (" + testName + ") -> " + outputFile.getAbsolutePath()); + + } catch (Exception e) { + System.err.println( + "[PgStatMonitor] Warning: Could not collect pg_stat_monitor data for " + datname + + ": " + e.getMessage()); + } + } + + private static int findColumnIndex(ResultSetMetaData meta, int columnCount, String name) + throws SQLException { + for (int i = 1; i <= columnCount; i++) { + if (name.equals(meta.getColumnName(i))) return i; + } + return -1; + } + + private static String escapeJsonString(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } +} diff --git a/src/test/java/io/supertokens/storage/postgresql/test/DbConnectionPoolTest.java b/src/test/java/io/supertokens/storage/postgresql/test/DbConnectionPoolTest.java index b6566021..e6450547 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/DbConnectionPoolTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/DbConnectionPoolTest.java @@ -63,6 +63,16 @@ public void beforeEach() { Utils.reset(); } + /** + * Helper method to get the tenant database name that matches what + * Start.modifyConfigToAddANewUserPoolForTesting() creates. + * The name is worker-specific to avoid conflicts during parallel test execution. + */ + private static String getTenantDatabaseName(int poolNumber) { + String workerId = System.getProperty("org.gradle.test.worker", ""); + return workerId.isEmpty() ? "st" + poolNumber : "st" + poolNumber + "_w" + workerId; + } + @Test public void testActiveConnectionsWithTenants() throws Exception { String[] args = {"../"}; @@ -76,7 +86,8 @@ public void testActiveConnectionsWithTenants() throws Exception { Thread.sleep(2000); // let all db connections establish Start start = (Start) StorageLayer.getBaseStorage(process.getProcess()); - assertEquals(10, start.getDbActivityCount("supertokens")); + String testDbName = DatabaseTestHelper.getCurrentTestDatabase(); + assertEquals(10, start.getDbActivityCount(testDbName)); JsonObject config = new JsonObject(); start.modifyConfigToAddANewUserPoolForTesting(config, 1); @@ -91,7 +102,8 @@ public void testActiveConnectionsWithTenants() throws Exception { Thread.sleep(1000); // let the new tenant be ready - assertEquals(10, start.getDbActivityCount("st1")); + String tenantDbName = getTenantDatabaseName(1); + assertEquals(10, start.getDbActivityCount(tenantDbName)); // change connection pool size config.addProperty("postgresql_connection_pool_size", 20); @@ -106,13 +118,13 @@ public void testActiveConnectionsWithTenants() throws Exception { Thread.sleep(2000); // let the new tenant be ready - assertEquals(20, start.getDbActivityCount("st1")); + assertEquals(20, start.getDbActivityCount(tenantDbName)); // delete tenant Multitenancy.deleteTenant(new TenantIdentifier(null, null, "t1"), process.getProcess()); Thread.sleep(2000); // let the tenant be deleted - assertEquals(0, start.getDbActivityCount("st1")); + assertEquals(0, start.getDbActivityCount(tenantDbName)); process.kill(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); @@ -133,7 +145,8 @@ public void testDownTimeWhenChangingConnectionPoolSize() throws Exception { Thread.sleep(2000); // let all db connections establish Start start = (Start) StorageLayer.getBaseStorage(process.getProcess()); - assertEquals(10, start.getDbActivityCount("supertokens")); + String testDbName = DatabaseTestHelper.getCurrentTestDatabase(); + assertEquals(10, start.getDbActivityCount(testDbName)); JsonObject config = new JsonObject(); start.modifyConfigToAddANewUserPoolForTesting(config, 1); @@ -152,7 +165,7 @@ public void testDownTimeWhenChangingConnectionPoolSize() throws Exception { Thread.sleep(15000); // let the new tenant be ready - assertEquals(300, start.getDbActivityCount("st1")); + assertEquals(300, start.getDbActivityCount(getTenantDatabaseName(1))); ExecutorService es = Executors.newFixedThreadPool(100); @@ -218,13 +231,13 @@ public void testDownTimeWhenChangingConnectionPoolSize() throws Exception { assertEquals(0, errorCount.get()); - assertEquals(200, start.getDbActivityCount("st1")); + assertEquals(200, start.getDbActivityCount(getTenantDatabaseName(1))); // delete tenant Multitenancy.deleteTenant(new TenantIdentifier(null, null, "t1"), process.getProcess()); Thread.sleep(3000); // let the tenant be deleted - assertEquals(0, start.getDbActivityCount("st1")); + assertEquals(0, start.getDbActivityCount(getTenantDatabaseName(1))); System.out.println(successAfterErrorTime.get() - firstErrorTime.get() + "ms"); assertTrue(successAfterErrorTime.get() - firstErrorTime.get() < 250); @@ -255,14 +268,16 @@ public void testMinimumIdleConnections() throws Exception { .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{EE_FEATURES.MULTI_TENANCY}); Utils.setValueInConfig("postgresql_connection_pool_size", "20"); Utils.setValueInConfig("postgresql_minimum_idle_connections", "10"); - Utils.setValueInConfig("postgresql_idle_connection_timeout", "30000"); + Utils.setValueInConfig("postgresql_idle_connection_timeout", "10000"); // HikariCP minimum is 10000ms + System.setProperty("com.zaxxer.hikari.housekeeping.periodMs", "1000"); process.startProcess(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); - Thread.sleep(65000); // let the idle connections time out + Thread.sleep(15000); // let the idle connections time out (10s idle + 1s housekeeping + buffer) Start start = (Start) StorageLayer.getBaseStorage(process.getProcess()); - assertEquals(10, start.getDbActivityCount("supertokens")); + String testDbName = DatabaseTestHelper.getCurrentTestDatabase(); + assertEquals(10, start.getDbActivityCount(testDbName)); process.kill(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); @@ -279,8 +294,11 @@ public void testMinimumIdleConnectionForTenants() throws Exception { process.startProcess(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + Thread.sleep(2000); // let all db connections establish + Start start = (Start) StorageLayer.getBaseStorage(process.getProcess()); - assertEquals(10, start.getDbActivityCount("supertokens")); + String testDbName = DatabaseTestHelper.getCurrentTestDatabase(); + assertEquals(10, start.getDbActivityCount(testDbName)); JsonObject config = new JsonObject(); start.modifyConfigToAddANewUserPoolForTesting(config, 1); @@ -297,7 +315,7 @@ public void testMinimumIdleConnectionForTenants() throws Exception { for (int retry = 0; retry < 5; retry++) { try { - assertEquals(10, start.getDbActivityCount("st1")); + assertEquals(10, start.getDbActivityCount(getTenantDatabaseName(1))); break; } catch (AssertionError e) { Thread.sleep(1000); @@ -305,7 +323,7 @@ public void testMinimumIdleConnectionForTenants() throws Exception { } } - assertEquals(10, start.getDbActivityCount("st1")); + assertEquals(10, start.getDbActivityCount(getTenantDatabaseName(1))); // change connection pool size config.addProperty("postgresql_connection_pool_size", 20); @@ -321,13 +339,13 @@ public void testMinimumIdleConnectionForTenants() throws Exception { Thread.sleep(2000); // let the new tenant be ready - assertEquals(5, start.getDbActivityCount("st1")); + assertEquals(5, start.getDbActivityCount(getTenantDatabaseName(1))); // delete tenant Multitenancy.deleteTenant(new TenantIdentifier(null, null, "t1"), process.getProcess()); Thread.sleep(2000); // let the tenant be deleted - assertEquals(0, start.getDbActivityCount("st1")); + assertEquals(0, start.getDbActivityCount(getTenantDatabaseName(1))); process.kill(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); @@ -346,13 +364,15 @@ public void testIdleConnectionTimeout() throws Exception { Thread.sleep(2000); // let all db connections establish Start start = (Start) StorageLayer.getBaseStorage(process.getProcess()); - assertEquals(10, start.getDbActivityCount("supertokens")); + String testDbName = DatabaseTestHelper.getCurrentTestDatabase(); + assertEquals(10, start.getDbActivityCount(testDbName)); JsonObject config = new JsonObject(); start.modifyConfigToAddANewUserPoolForTesting(config, 1); config.addProperty("postgresql_connection_pool_size", 300); config.addProperty("postgresql_minimum_idle_connections", 5); - config.addProperty("postgresql_idle_connection_timeout", 30000); + config.addProperty("postgresql_idle_connection_timeout", 10000); // HikariCP minimum is 10000ms + System.setProperty("com.zaxxer.hikari.housekeeping.periodMs", "1000"); AtomicLong errorCount = new AtomicLong(0); @@ -366,11 +386,11 @@ public void testIdleConnectionTimeout() throws Exception { Thread.sleep(3000); // let the new tenant be ready - assertTrue(10 >= start.getDbActivityCount("st1")); + assertTrue(10 >= start.getDbActivityCount(getTenantDatabaseName(1))); ExecutorService es = Executors.newFixedThreadPool(150); - for (int i = 0; i < 10000; i++) { + for (int i = 0; i < 1000; i++) { int finalI = i; es.execute(() -> { try { @@ -398,19 +418,19 @@ public void testIdleConnectionTimeout() throws Exception { es.shutdown(); es.awaitTermination(2, TimeUnit.MINUTES); - assertTrue(5 < start.getDbActivityCount("st1")); + assertTrue(5 < start.getDbActivityCount(getTenantDatabaseName(1))); assertEquals(0, errorCount.get()); - Thread.sleep(65000); // let the idle connections time out + Thread.sleep(15000); // let the idle connections time out (10s idle + 1s housekeeping + buffer) - assertEquals(5, start.getDbActivityCount("st1")); + assertEquals(5, start.getDbActivityCount(getTenantDatabaseName(1))); // delete tenant Multitenancy.deleteTenant(new TenantIdentifier(null, null, "t1"), process.getProcess()); Thread.sleep(3000); // let the tenant be deleted - assertEquals(0, start.getDbActivityCount("st1")); + assertEquals(0, start.getDbActivityCount(getTenantDatabaseName(1))); process.kill(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); diff --git a/src/test/java/io/supertokens/storage/postgresql/test/DeadlockTest.java b/src/test/java/io/supertokens/storage/postgresql/test/DeadlockTest.java index 2e232198..f9dc08dd 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/DeadlockTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/DeadlockTest.java @@ -186,11 +186,11 @@ public void testCodeCreationRapidly() throws Exception { TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); - ExecutorService es = Executors.newFixedThreadPool(1000); + ExecutorService es = Executors.newFixedThreadPool(200); AtomicBoolean pass = new AtomicBoolean(true); - for (int i = 0; i < 3000; i++) { + for (int i = 0; i < 500; i++) { es.execute(() -> { try { Passwordless.CreateCodeResponse resp = Passwordless.createCode(process.getProcess(), @@ -207,8 +207,9 @@ public void testCodeCreationRapidly() throws Exception { } es.shutdown(); - es.awaitTermination(2, TimeUnit.MINUTES); + es.awaitTermination(60, TimeUnit.SECONDS); + assertTrue("Executor didn't finish in time", es.isTerminated()); assert (pass.get()); process.kill(); @@ -622,7 +623,7 @@ public void testLinkAccountsInParallel() throws Exception { process.startProcess(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); - ExecutorService es = Executors.newFixedThreadPool(1000); + ExecutorService es = Executors.newFixedThreadPool(200); AtomicBoolean pass = new AtomicBoolean(true); @@ -631,7 +632,7 @@ public void testLinkAccountsInParallel() throws Exception { AuthRecipe.createPrimaryUser(process.getProcess(), user1.getSupertokensUserId()); - for (int i = 0; i < 3000; i++) { + for (int i = 0; i < 500; i++) { es.execute(() -> { try { AuthRecipe.linkAccounts(process.getProcess(), user2.getSupertokensUserId(), @@ -646,8 +647,9 @@ public void testLinkAccountsInParallel() throws Exception { } es.shutdown(); - es.awaitTermination(2, TimeUnit.MINUTES); + es.awaitTermination(60, TimeUnit.SECONDS); + assertTrue("Executor didn't finish in time", es.isTerminated()); assert (pass.get()); // No longer deadlocks? This should be OK. @@ -675,13 +677,13 @@ public void testCreatePrimaryInParallel() throws Exception { process.startProcess(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); - ExecutorService es = Executors.newFixedThreadPool(1000); + ExecutorService es = Executors.newFixedThreadPool(200); AtomicBoolean pass = new AtomicBoolean(true); AuthRecipeUserInfo user1 = EmailPassword.signUp(process.getProcess(), "test1@example.com", "password"); - for (int i = 0; i < 3000; i++) { + for (int i = 0; i < 500; i++) { es.execute(() -> { try { AuthRecipe.createPrimaryUser(process.getProcess(), user1.getSupertokensUserId()); @@ -696,8 +698,9 @@ public void testCreatePrimaryInParallel() throws Exception { } es.shutdown(); - es.awaitTermination(2, TimeUnit.MINUTES); + es.awaitTermination(60, TimeUnit.SECONDS); + assertTrue("Executor didn't finish in time", es.isTerminated()); assert (pass.get()); // TODO: these no longer deadlock. This is OK? // assertNull(process diff --git a/src/test/java/io/supertokens/storage/postgresql/test/ExceptionParsingTest.java b/src/test/java/io/supertokens/storage/postgresql/test/ExceptionParsingTest.java index 6761a6f6..4134f405 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/ExceptionParsingTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/ExceptionParsingTest.java @@ -18,7 +18,9 @@ package io.supertokens.storage.postgresql.test; import io.supertokens.ProcessState; +import io.supertokens.authRecipe.AuthRecipe; import io.supertokens.pluginInterface.RECIPE_ID; +import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; import io.supertokens.pluginInterface.emailpassword.PasswordResetTokenInfo; import io.supertokens.pluginInterface.emailpassword.exceptions.DuplicateEmailException; import io.supertokens.pluginInterface.emailpassword.exceptions.DuplicatePasswordResetTokenException; @@ -57,6 +59,7 @@ import static junit.framework.TestCase.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; public class ExceptionParsingTest { @Rule @@ -109,6 +112,13 @@ public void thirdPartySignupExceptions() throws Exception { assertEquals(storage.getUsersCount(new TenantIdentifier(null, null, null), new RECIPE_ID[]{RECIPE_ID.THIRD_PARTY}), 1); + // Verify reservation table integrity for the successfully created user + AuthRecipeUserInfo tpUser = AuthRecipe.getUserById(process.getProcess(), userId); + assertNotNull(tpUser); + RaceTestUtils.ConsistencyCheckResult tpResult = RaceTestUtils.checkReservationConsistency( + process.getProcess(), tpUser); + assertTrue("ThirdParty reservation inconsistency: " + tpResult.issues, tpResult.isConsistent); + process.kill(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); } @@ -148,6 +158,13 @@ public void emailPasswordSignupExceptions() throws Exception { assertEquals(storage.getUsersCount(new TenantIdentifier(null, null, null), new RECIPE_ID[]{RECIPE_ID.EMAIL_PASSWORD}), 1); + // Verify reservation table integrity for the successfully created user + AuthRecipeUserInfo epUser = AuthRecipe.getUserById(process.getProcess(), userId); + assertNotNull(epUser); + RaceTestUtils.ConsistencyCheckResult epResult = RaceTestUtils.checkReservationConsistency( + process.getProcess(), epUser); + assertTrue("EmailPassword reservation inconsistency: " + epResult.issues, epResult.isConsistent); + process.kill(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); } diff --git a/src/test/java/io/supertokens/storage/postgresql/test/LogLevelTest.java b/src/test/java/io/supertokens/storage/postgresql/test/LogLevelTest.java index e675c765..3fb9b458 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/LogLevelTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/LogLevelTest.java @@ -23,6 +23,7 @@ import io.supertokens.storage.postgresql.output.Logging; import io.supertokens.storageLayer.StorageLayer; import org.junit.AfterClass; +import org.junit.Assume; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -50,6 +51,8 @@ public void beforeEach() { @Test public void testLogLevelNoneOutput() throws Exception { + // Skip test if file logging is disabled (logs go to console instead) + Assume.assumeTrue("Skipping - file logging is disabled", Utils.isFileLoggingEnabled()); { Utils.setValueInConfig("log_level", "NONE"); String[] args = {"../"}; @@ -94,6 +97,8 @@ public void testLogLevelNoneOutput() throws Exception { @Test public void testLogLevelErrorOutput() throws Exception { + // Skip test if file logging is disabled (logs go to console instead) + Assume.assumeTrue("Skipping - file logging is disabled", Utils.isFileLoggingEnabled()); { Utils.setValueInConfig("log_level", "ERROR"); String[] args = {"../"}; @@ -148,6 +153,8 @@ public void testLogLevelErrorOutput() throws Exception { @Test public void testLogLevelWarnOutput() throws Exception { + // Skip test if file logging is disabled (logs go to console instead) + Assume.assumeTrue("Skipping - file logging is disabled", Utils.isFileLoggingEnabled()); { Utils.setValueInConfig("log_level", "WARN"); String[] args = {"../"}; @@ -202,6 +209,8 @@ public void testLogLevelWarnOutput() throws Exception { @Test public void testLogLevelInfoOutput() throws Exception { + // Skip test if file logging is disabled (logs go to console instead) + Assume.assumeTrue("Skipping - file logging is disabled", Utils.isFileLoggingEnabled()); { Utils.setValueInConfig("log_level", "INFO"); String[] args = {"../"}; @@ -256,6 +265,8 @@ public void testLogLevelInfoOutput() throws Exception { @Test public void testLogLevelDebugOutput() throws Exception { + // Skip test if file logging is disabled (logs go to console instead) + Assume.assumeTrue("Skipping - file logging is disabled", Utils.isFileLoggingEnabled()); { Utils.setValueInConfig("log_level", "DEBUG"); String[] args = {"../"}; diff --git a/src/test/java/io/supertokens/storage/postgresql/test/LoggingTest.java b/src/test/java/io/supertokens/storage/postgresql/test/LoggingTest.java index 022414f1..8f2974e4 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/LoggingTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/LoggingTest.java @@ -47,6 +47,7 @@ import java.util.Scanner; import static org.junit.Assert.*; +import static org.junit.Assume.assumeTrue; public class LoggingTest { @Rule @@ -64,6 +65,9 @@ public void beforeEach() { @Test public void defaultLogging() throws Exception { + // Skip this test if file logging is disabled (envvar set to null) + assumeTrue("File logging is disabled via environment variable", Utils.isFileLoggingEnabled()); + String[] args = {"../"}; StorageLayer.close(); TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); @@ -107,6 +111,9 @@ public void defaultLogging() throws Exception { @Test public void customLogging() throws Exception { + // Skip this test if file logging is disabled (envvar set to null) + assumeTrue("File logging is disabled via environment variable", Utils.isFileLoggingEnabled()); + try { String[] args = {"../"}; @@ -319,7 +326,7 @@ public void testDBPasswordMaskingOnDBConnectionFailUsingConnectionUri() throws E String dbUser = "db_user"; String dbPassword = "db_password"; String dbName = "db_does_not_exist"; - String dbConnectionUri = "postgresql://" + dbUser + ":" + dbPassword + "@localhost:5432/" + dbName; + String dbConnectionUri = "postgresql://" + dbUser + ":" + dbPassword + "@" + DatabaseTestHelper.getHost() + ":" + DatabaseTestHelper.getPort() + "/" + dbName; Utils.setValueInConfig("postgresql_connection_uri", dbConnectionUri); Utils.setValueInConfig("error_log_path", "null"); @@ -451,9 +458,9 @@ public void testDBPasswordIsNotLoggedWhenProcessStartsEnds() throws Exception { String dbUser = "db_user"; String dbPassword = "db_password"; String dbName = "db_does_not_exist"; - String dbConnectionUri = "postgresql://" + dbUser + ":" + dbPassword + "@localhost:5432/" + dbName; - - Utils.setValueInConfig("postgresql_connection_uri", dbConnectionUri); + Utils.setValueInConfig("postgresql_database_name", dbName); + Utils.setValueInConfig("postgresql_use", dbUser); + Utils.setValueInConfig("postgresql_password", dbPassword); TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); diff --git a/src/test/java/io/supertokens/storage/postgresql/test/MultitenancyRaceTest.java b/src/test/java/io/supertokens/storage/postgresql/test/MultitenancyRaceTest.java index b5b9c0cc..28a3c78e 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/MultitenancyRaceTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/MultitenancyRaceTest.java @@ -178,7 +178,7 @@ public void testTenantAddedDuringLinkAccounts() throws Exception { if (isLinked) { // CRITICAL: Check reservation tables directly for ALL tenants the user is in - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( main, finalUser); if (!result.isConsistent) { @@ -298,7 +298,7 @@ public void testAllTenantsHaveReservationAfterLink() throws Exception { if (isLinked && email != null) { // CRITICAL: Check reservation tables directly for ALL tenants - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( main, finalUser); if (!result.isConsistent) { @@ -409,7 +409,7 @@ public void testLinkingDuringTenantAddition() throws Exception { if (isLinked) { // CRITICAL: Check reservation tables directly for ALL tenants - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( main, finalUser); if (!result.isConsistent) { @@ -539,7 +539,7 @@ public void testMultipleTenantsAddedConcurrentlyWithLink() throws Exception { if (isLinked) { // CRITICAL: Check reservation tables directly for ALL tenants - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( main, finalUser); if (!result.isConsistent) { @@ -1002,7 +1002,7 @@ public void testRapidTenantOperationsWithLinking() throws Exception { if (isLinked) { // CRITICAL: Check reservation tables directly for ALL tenants - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( main, finalUser); if (!result.isConsistent) { diff --git a/src/test/java/io/supertokens/storage/postgresql/test/PasswordlessRaceTest.java b/src/test/java/io/supertokens/storage/postgresql/test/PasswordlessRaceTest.java index 367c75d2..a4843034 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/PasswordlessRaceTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/PasswordlessRaceTest.java @@ -163,7 +163,7 @@ public void testLinkDuringPasswordlessEmailUpdate() throws Exception { if (isLinked && actualEmail != null) { // CRITICAL: Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( process.getProcess(), finalUser); if (!result.isConsistent) { @@ -347,7 +347,7 @@ public void testPasswordlessReadOutsideTransactionRace() throws Exception { if (isLinked) { // CRITICAL: Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( process.getProcess(), finalUser); if (!result.isConsistent) { @@ -579,7 +579,7 @@ public void testRapidPasswordlessUpdatesWithLinking() throws Exception { if (isLinked) { // CRITICAL: Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( process.getProcess(), finalUser); if (!result.isConsistent) { diff --git a/src/test/java/io/supertokens/storage/postgresql/test/RaceConditionTest.java b/src/test/java/io/supertokens/storage/postgresql/test/RaceConditionTest.java index a893ab8a..f599679f 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/RaceConditionTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/RaceConditionTest.java @@ -142,7 +142,7 @@ public void testLinkAccountsDuringEmailPasswordEmailUpdate() throws Exception { assertNotNull(finalUser); // Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( process.getProcess(), finalUser); if (!result.isConsistent) { @@ -247,7 +247,7 @@ public void testLinkAccountsEmailUpdateHighConcurrency() throws Exception { } // Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( process.getProcess(), finalUser); if (!result.isConsistent) { @@ -338,7 +338,7 @@ public void testEmailUpdateDuringLinkAccounts() throws Exception { assertNotNull(finalUser); // Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( process.getProcess(), finalUser); if (!result.isConsistent) { @@ -449,7 +449,7 @@ public void testReservationCompletenessAfterConcurrentOps() throws Exception { } // Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( process.getProcess(), finalUser); if (!result.isConsistent) { @@ -558,7 +558,7 @@ public void testRapidLinkUnlinkWithEmailUpdates() throws Exception { if (finalUser != null) { // Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( process.getProcess(), finalUser); if (!result.isConsistent) { diff --git a/src/test/java/io/supertokens/storage/postgresql/test/RaceTestUtils.java b/src/test/java/io/supertokens/storage/postgresql/test/RaceTestUtils.java index 860b042e..86d0160e 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/RaceTestUtils.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/RaceTestUtils.java @@ -17,24 +17,25 @@ package io.supertokens.storage.postgresql.test; import io.supertokens.Main; +import io.supertokens.pluginInterface.RECIPE_ID; +import io.supertokens.pluginInterface.authRecipe.ACCOUNT_INFO_TYPE; import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; import io.supertokens.pluginInterface.authRecipe.LoginMethod; +import io.supertokens.pluginInterface.multitenancy.AppIdentifier; import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; import io.supertokens.storage.postgresql.Start; import io.supertokens.storage.postgresql.config.Config; import io.supertokens.storageLayer.StorageLayer; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; +import java.util.*; import static io.supertokens.storage.postgresql.QueryExecutorTemplate.execute; /** * Utility class for race condition tests that need to directly query - * the reservation tables (primary_user_tenants, recipe_user_tenants) - * to verify consistency between user data and table reservations. + * the reservation tables (primary_user_tenants, recipe_user_tenants, + * recipe_user_account_infos, app_id_to_user_id) to verify consistency + * between user data and table reservations. */ public class RaceTestUtils { @@ -60,29 +61,43 @@ public String toString() { } /** - * Check complete consistency for a user object against reservation tables. + * Backward-compatible delegate: checks only EMAIL reservations (I1-I3). + */ + public static ConsistencyCheckResult checkEmailReservationConsistency(Main main, AuthRecipeUserInfo user) + throws Exception { + return checkReservationConsistency(main, user); + } + + /** + * Check complete consistency for a user object against all reservation tables. * - * This method verifies the following invariants (for email account_info_type only): + * This method verifies the following invariants for ALL account_info_types + * (EMAIL, PHONE_NUMBER, THIRD_PARTY): * - * I1 (Primary Reservation Completeness): For every linked recipe user's email, - * that email must be reserved in primary_user_tenants for the primary user. + * I1 (Primary Reservation Completeness): For every linked recipe user's account info, + * that info must be reserved in primary_user_tenants for the primary user. * - * I2 (Primary Reservation Accuracy): Every email in primary_user_tenants must - * correspond to an actual login method's email (no orphaned reservations). + * I2 (Primary Reservation Accuracy): Every entry in primary_user_tenants must + * correspond to an actual login method's account info (no orphaned reservations). * * I3 (Recipe Tables Consistency): recipe_user_tenants must match the login method's - * actual email (no missing, mismatched, or orphaned entries). + * actual account info (no missing, mismatched, or orphaned entries). + * + * I4 (Recipe User Tenants Row Count): Every recipe user has the correct number of + * recipe_user_tenants rows per tenant (e.g., passwordless with both email+phone = 2). * - * Note: This method does NOT verify: - * - Phone numbers or third-party identities (only emails) - * - I4 (Recipe User Uniqueness) - same email across different recipe users - * - recipe_user_account_infos table consistency + * I5 (Recipe User Account Infos Row Count): Every recipe user has the correct number + * of recipe_user_account_infos rows (app-scoped). + * + * I6 (Time Joined Consistency): primary_or_recipe_user_time_joined in app_id_to_user_id + * is consistent for all rows sharing the same primary_or_recipe_user_id and equals + * MIN(time_joined) of the group. * * @param main The Main process * @param user The AuthRecipeUserInfo to check (from getUserById) * @return ConsistencyCheckResult indicating if the reservations are consistent */ - public static ConsistencyCheckResult checkEmailReservationConsistency(Main main, AuthRecipeUserInfo user) + public static ConsistencyCheckResult checkReservationConsistency(Main main, AuthRecipeUserInfo user) throws Exception { List issues = new ArrayList<>(); @@ -92,174 +107,424 @@ public static ConsistencyCheckResult checkEmailReservationConsistency(Main main, } String primaryUserId = user.getSupertokensUserId(); - - // Check primary_user_tenants if user is a primary user. - // Note: When querying by a linked recipe user's ID, getUserById returns the primary user, - // so isPrimaryUser will be true. loginMethods.length > 1 is a redundant check since - // multiple login methods implies linking which requires a primary user. boolean shouldCheckPrimaryUserTenants = user.isPrimaryUser; // For each tenant the user is in, check consistency for (String tenantId : user.tenantIds) { TenantIdentifier tenant = new TenantIdentifier(null, null, tenantId); - // 1. Check primary_user_tenants if user is a primary user (verifies I1 and I2) + // I1 + I2: Check primary_user_tenants for ALL account info types if (shouldCheckPrimaryUserTenants) { - List primaryIssues = checkPrimaryUserTenantsConsistency(main, tenant, user); - issues.addAll(primaryIssues); + issues.addAll(checkPrimaryUserTenantsConsistency(main, tenant, user, ACCOUNT_INFO_TYPE.EMAIL)); + issues.addAll(checkPrimaryUserTenantsConsistency(main, tenant, user, ACCOUNT_INFO_TYPE.PHONE_NUMBER)); + issues.addAll(checkPrimaryUserTenantsConsistency(main, tenant, user, ACCOUNT_INFO_TYPE.THIRD_PARTY)); } - // 2. Check recipe_user_tenants for each login method in this tenant (verifies I3) + // I3 + I4: Check recipe_user_tenants for each login method in this tenant for (LoginMethod loginMethod : user.loginMethods) { if (loginMethod.tenantIds.contains(tenantId)) { - List recipeIssues = checkRecipeUserTenantsConsistency(main, tenant, loginMethod); - issues.addAll(recipeIssues); + issues.addAll(checkRecipeUserTenantsConsistency(main, tenant, loginMethod)); + issues.addAll(checkRecipeUserTenantsRowCount(main, tenant, loginMethod)); } } } + // I5: Check recipe_user_account_infos row count (app-scoped, not tenant-scoped) + for (LoginMethod loginMethod : user.loginMethods) { + issues.addAll(checkRecipeUserAccountInfosRowCount(main, loginMethod)); + } + + // I6: Check time_joined consistency in app_id_to_user_id + issues.addAll(checkTimeJoinedConsistency(main, user)); + return new ConsistencyCheckResult(issues.isEmpty(), issues); } + // ======================== I1 + I2: Primary User Tenants ======================== + /** - * Check that primary_user_tenants exactly matches the linked recipe users for a tenant. + * Check that primary_user_tenants exactly matches the linked recipe users for a tenant + * and a given account_info_type. * - * Verifies I1 (Primary Reservation Completeness) and I2 (Primary Reservation Accuracy): - * - I1: All emails from all login methods must be reserved in this tenant - * - I2: Each reserved email must correspond to some login method's email in the linked group - * - * IMPORTANT: The implementation reserves ALL emails from ALL login methods in ALL tenants - * where ANY linked user exists. This is intentional to prevent identity conflicts: - * - If P1 links R1, and P1 is only in tenant1 but R1 is in tenant1+tenant2 - * - P1's email must be reserved in tenant2 too, even though P1's login method isn't there - * - Otherwise another primary could claim P1's email in tenant2, creating a conflict + * I1: All account info values from all login methods must be reserved. + * I2: Each reserved value must correspond to an actual login method's value. */ private static List checkPrimaryUserTenantsConsistency(Main main, TenantIdentifier tenant, - AuthRecipeUserInfo user) throws Exception { + AuthRecipeUserInfo user, + ACCOUNT_INFO_TYPE accountInfoType) + throws Exception { List issues = new ArrayList<>(); String primaryUserId = user.getSupertokensUserId(); - // Get all email reservations from primary_user_tenants for this primary user in this tenant - Set reservedEmails = getAllPrimaryUserEmailReservations(main, tenant, primaryUserId); + // Get all reservations from primary_user_tenants for this type + Set reserved = getAllPrimaryUserReservations(main, tenant, primaryUserId, accountInfoType); - // Build expected set of emails from ALL login methods (not filtered by tenant). - // The implementation reserves all emails in all tenants where any linked user exists, - // to prevent identity conflicts across the linked group. - Set expectedEmails = new HashSet<>(); + // Build expected set from ALL login methods + Set expected = new HashSet<>(); for (LoginMethod lm : user.loginMethods) { - if (lm.email != null) { - expectedEmails.add(lm.email); + for (String value : getAccountInfoValues(lm, accountInfoType)) { + expected.add(value); } } - // Check for missing reservations (emails in user but not in table) - I1 violation - for (String expectedEmail : expectedEmails) { - if (!reservedEmails.contains(expectedEmail)) { - issues.add("MISSING PRIMARY RESERVATION (I1 violation): Email '" + expectedEmail + + String typeName = accountInfoType.toString(); + + // I1: Missing reservations + for (String expectedValue : expected) { + if (!reserved.contains(expectedValue)) { + issues.add("MISSING PRIMARY RESERVATION (I1 violation): " + typeName + " '" + expectedValue + "' is in user's login methods but NOT in primary_user_tenants for primary user " + primaryUserId + " in tenant '" + tenant.getTenantId() + - "'. Reserved emails: " + reservedEmails); + "'. Reserved: " + reserved); } } - // Check for orphaned reservations (emails in table but not in any login method) - I2 violation - for (String reservedEmail : reservedEmails) { - if (!expectedEmails.contains(reservedEmail)) { - issues.add("ORPHANED PRIMARY RESERVATION (I2 violation): Email '" + reservedEmail + + // I2: Orphaned reservations + for (String reservedValue : reserved) { + if (!expected.contains(reservedValue)) { + issues.add("ORPHANED PRIMARY RESERVATION (I2 violation): " + typeName + " '" + reservedValue + "' is in primary_user_tenants for primary user " + primaryUserId + " in tenant '" + tenant.getTenantId() + - "' but NOT in any login method. Expected emails: " + expectedEmails); + "' but NOT in any login method. Expected: " + expected); } } return issues; } + // ======================== I3: Recipe User Tenants Consistency ======================== + /** - * Check that recipe_user_tenants exactly matches the login method's email for a tenant. - * - * Verifies I3 (Recipe Tables Consistency) for the email account_info_type: - * - If login method has email, recipe_user_tenants must have matching entry - * - If login method has no email, recipe_user_tenants must NOT have an email entry (orphan check) + * Check that recipe_user_tenants matches the login method's actual account info. + * Checks all account info types (email, phone, third_party). */ private static List checkRecipeUserTenantsConsistency(Main main, TenantIdentifier tenant, LoginMethod loginMethod) throws Exception { List issues = new ArrayList<>(); String recipeUserId = loginMethod.getSupertokensUserId(); - String expectedEmail = loginMethod.email; - - // Get email reservation from recipe_user_tenants - String reservedEmail = getRecipeUserEmailReservation(main, tenant, recipeUserId); - - if (expectedEmail != null) { - if (reservedEmail == null) { - issues.add("MISSING RECIPE RESERVATION (I3 violation): Login method " + recipeUserId + - " has email '" + expectedEmail + "' in tenant '" + tenant.getTenantId() + - "' but NO reservation in recipe_user_tenants"); - } else if (!reservedEmail.equals(expectedEmail)) { - issues.add("MISMATCHED RECIPE RESERVATION (I3 violation): Login method " + recipeUserId + - " has email '" + expectedEmail + "' but recipe_user_tenants has '" + - reservedEmail + "' in tenant '" + tenant.getTenantId() + "'"); + + for (ACCOUNT_INFO_TYPE type : ACCOUNT_INFO_TYPE.values()) { + Set expectedValues = getAccountInfoValues(loginMethod, type); + Set reservedValues = getRecipeUserReservations(main, tenant, recipeUserId, type); + + String typeName = type.toString(); + + // Missing + for (String expected : expectedValues) { + if (!reservedValues.contains(expected)) { + issues.add("MISSING RECIPE RESERVATION (I3 violation): Login method " + recipeUserId + + " has " + typeName + " '" + expected + "' in tenant '" + tenant.getTenantId() + + "' but NO reservation in recipe_user_tenants"); + } + } + + // Orphaned + for (String reserved : reservedValues) { + if (!expectedValues.contains(reserved)) { + issues.add("ORPHANED RECIPE RESERVATION (I3 violation): Login method " + recipeUserId + + " has no matching " + typeName + " for value '" + reserved + + "' in recipe_user_tenants in tenant '" + tenant.getTenantId() + "'"); + } + } + } + + return issues; + } + + // ======================== I4: Recipe User Tenants Row Count ======================== + + /** + * Check that recipe_user_tenants has the expected number of rows for a login method + * in a given tenant. For example, a passwordless user with both email AND phone + * should have 2 rows per tenant. + */ + private static List checkRecipeUserTenantsRowCount(Main main, TenantIdentifier tenant, + LoginMethod loginMethod) throws Exception { + List issues = new ArrayList<>(); + String recipeUserId = loginMethod.getSupertokensUserId(); + + int expectedCount = countExpectedAccountInfos(loginMethod); + int actualCount = getRecipeUserTenantsCount(main, tenant, recipeUserId); + + if (actualCount != expectedCount) { + issues.add("WRONG ROW COUNT (I4 violation): Login method " + recipeUserId + + " has " + actualCount + " rows in recipe_user_tenants for tenant '" + + tenant.getTenantId() + "' but expected " + expectedCount); + } + + return issues; + } + + // ======================== I5: Recipe User Account Infos Row Count ======================== + + /** + * Check that recipe_user_account_infos has the expected number of rows for a login method. + * This table is app-scoped (not tenant-scoped). + */ + private static List checkRecipeUserAccountInfosRowCount(Main main, LoginMethod loginMethod) + throws Exception { + List issues = new ArrayList<>(); + String recipeUserId = loginMethod.getSupertokensUserId(); + + int expectedCount = countExpectedAccountInfos(loginMethod); + int actualCount = getRecipeUserAccountInfosCount(main, recipeUserId); + + if (actualCount != expectedCount) { + issues.add("WRONG ROW COUNT (I5 violation): Login method " + recipeUserId + + " has " + actualCount + " rows in recipe_user_account_infos but expected " + expectedCount); + } + + return issues; + } + + // ======================== I6: Time Joined Consistency ======================== + + /** + * Check that primary_or_recipe_user_time_joined is consistent in app_id_to_user_id: + * all rows sharing the same primary_or_recipe_user_id must have the same time_joined value, + * and it must equal MIN(time_joined) of the group. + */ + private static List checkTimeJoinedConsistency(Main main, AuthRecipeUserInfo user) throws Exception { + List issues = new ArrayList<>(); + + if (!user.isPrimaryUser || user.loginMethods.length <= 1) { + return issues; + } + + Start start = (Start) StorageLayer.getStorage(main); + String table = Config.getConfig(start).getAppIdToUserIdTable(); + + // Get all rows for users in this linked group + String primaryUserId = user.getSupertokensUserId(); + List userIds = new ArrayList<>(); + userIds.add(primaryUserId); + for (LoginMethod lm : user.loginMethods) { + if (!lm.getSupertokensUserId().equals(primaryUserId)) { + userIds.add(lm.getSupertokensUserId()); + } + } + + String placeholders = String.join(",", Collections.nCopies(userIds.size(), "?")); + String QUERY = "SELECT user_id, primary_or_recipe_user_time_joined FROM " + table + + " WHERE app_id = ? AND user_id IN (" + placeholders + ")"; + + Map timeJoinedMap = execute(start, QUERY, pst -> { + pst.setString(1, "public"); + for (int i = 0; i < userIds.size(); i++) { + pst.setString(i + 2, userIds.get(i)); } - } else { - // Login method has no email - check for orphaned entries - if (reservedEmail != null) { - issues.add("ORPHANED RECIPE RESERVATION (I3 violation): Login method " + recipeUserId + - " has NO email but recipe_user_tenants has '" + reservedEmail + - "' in tenant '" + tenant.getTenantId() + "'"); + }, rs -> { + Map map = new HashMap<>(); + while (rs.next()) { + map.put(rs.getString("user_id").trim(), rs.getLong("primary_or_recipe_user_time_joined")); + } + return map; + }); + + // All should have the same primary_or_recipe_user_time_joined + Set distinctTimeJoined = new HashSet<>(timeJoinedMap.values()); + if (distinctTimeJoined.size() > 1) { + issues.add("INCONSISTENT TIME_JOINED (I6 violation): Users in linked group of primary " + + primaryUserId + " have different primary_or_recipe_user_time_joined values: " + timeJoinedMap); + } + + // The time_joined should equal MIN(time_joined) from login methods + long minTimeJoined = Long.MAX_VALUE; + for (LoginMethod lm : user.loginMethods) { + if (lm.timeJoined < minTimeJoined) { + minTimeJoined = lm.timeJoined; + } + } + + for (Map.Entry entry : timeJoinedMap.entrySet()) { + if (entry.getValue() != minTimeJoined) { + issues.add("WRONG TIME_JOINED (I6 violation): User " + entry.getKey() + + " has primary_or_recipe_user_time_joined=" + entry.getValue() + + " but expected MIN(time_joined)=" + minTimeJoined + + " for primary user " + primaryUserId); } } return issues; } + // ======================== Helper: Account Info Value Extraction ======================== + + /** + * Extract account info values from a LoginMethod for a given type. + * + * In the reservation tables: + * - EMAIL type: account_info_value = the email address + * - PHONE_NUMBER type: account_info_value = the phone number + * - THIRD_PARTY type: account_info_value = "thirdPartyId::thirdPartyUserId" + * + * Note: For third-party users, the email is stored in a separate EMAIL row + * with third_party_id and third_party_user_id fields populated. + */ + private static Set getAccountInfoValues(LoginMethod lm, ACCOUNT_INFO_TYPE type) { + Set values = new HashSet<>(); + switch (type) { + case EMAIL: + if (lm.email != null) { + values.add(lm.email); + } + break; + case PHONE_NUMBER: + if (lm.phoneNumber != null) { + values.add(lm.phoneNumber); + } + break; + case THIRD_PARTY: + if (lm.thirdParty != null) { + // THIRD_PARTY rows store "id::userId" as account_info_value + values.add(lm.thirdParty.getAccountInfoValue()); + } + break; + } + return values; + } + + /** + * Count the expected number of account info entries for a login method. + * Third-party users have 2 rows: one EMAIL + one THIRD_PARTY. + * Passwordless users may have 1 (email or phone) or 2 (email + phone). + * EmailPassword users have 1 (email). + */ + private static int countExpectedAccountInfos(LoginMethod lm) { + int count = 0; + if (lm.email != null) { + count++; + } + if (lm.phoneNumber != null) { + count++; + } + if (lm.thirdParty != null) { + // Third party has its own row (type=tparty) in addition to email row + count++; + } + return count; + } + + // ======================== SQL Query Helpers ======================== + /** - * Get all email reservations for a primary user in a tenant from primary_user_tenants table + * Get all reservations for a primary user in a tenant from primary_user_tenants table + * for a given account_info_type. */ - public static Set getAllPrimaryUserEmailReservations(Main main, TenantIdentifier tenant, String primaryUserId) + public static Set getAllPrimaryUserReservations(Main main, TenantIdentifier tenant, + String primaryUserId, ACCOUNT_INFO_TYPE type) throws Exception { Start start = (Start) StorageLayer.getStorage(main); String tableName = Config.getConfig(start).getPrimaryUserTenantsTable(); String QUERY = "SELECT account_info_value FROM " + tableName + - " WHERE app_id = ? AND tenant_id = ? AND primary_user_id = ? AND account_info_type = 'email'"; + " WHERE app_id = ? AND tenant_id = ? AND primary_user_id = ? AND account_info_type = ?"; return execute(start, QUERY, pst -> { pst.setString(1, tenant.getAppId()); pst.setString(2, tenant.getTenantId()); pst.setString(3, primaryUserId); + pst.setString(4, type.toString()); }, rs -> { - Set emails = new HashSet<>(); + Set values = new HashSet<>(); while (rs.next()) { - emails.add(rs.getString("account_info_value")); + values.add(rs.getString("account_info_value")); } - return emails; + return values; }); } /** - * Get recipe user email reservation for a specific user in a tenant from recipe_user_tenants table + * Get all reservations for a recipe user in a tenant from recipe_user_tenants table + * for a given account_info_type. */ - public static String getRecipeUserEmailReservation(Main main, TenantIdentifier tenant, String recipeUserId) + public static Set getRecipeUserReservations(Main main, TenantIdentifier tenant, + String recipeUserId, ACCOUNT_INFO_TYPE type) throws Exception { Start start = (Start) StorageLayer.getStorage(main); String tableName = Config.getConfig(start).getRecipeUserTenantsTable(); String QUERY = "SELECT account_info_value FROM " + tableName + - " WHERE app_id = ? AND tenant_id = ? AND recipe_user_id = ? AND account_info_type = 'email'"; + " WHERE app_id = ? AND tenant_id = ? AND recipe_user_id = ? AND account_info_type = ?"; return execute(start, QUERY, pst -> { pst.setString(1, tenant.getAppId()); pst.setString(2, tenant.getTenantId()); pst.setString(3, recipeUserId); + pst.setString(4, type.toString()); }, rs -> { - if (rs.next()) { - return rs.getString("account_info_value"); + Set values = new HashSet<>(); + while (rs.next()) { + values.add(rs.getString("account_info_value")); } - return null; + return values; + }); + } + + /** + * Get total row count in recipe_user_tenants for a recipe user in a tenant. + */ + private static int getRecipeUserTenantsCount(Main main, TenantIdentifier tenant, String recipeUserId) + throws Exception { + Start start = (Start) StorageLayer.getStorage(main); + String tableName = Config.getConfig(start).getRecipeUserTenantsTable(); + + String QUERY = "SELECT COUNT(*) as cnt FROM " + tableName + + " WHERE app_id = ? AND tenant_id = ? AND recipe_user_id = ?"; + + return execute(start, QUERY, pst -> { + pst.setString(1, tenant.getAppId()); + pst.setString(2, tenant.getTenantId()); + pst.setString(3, recipeUserId); + }, rs -> { + rs.next(); + return rs.getInt("cnt"); + }); + } + + /** + * Get total row count in recipe_user_account_infos for a recipe user. + */ + private static int getRecipeUserAccountInfosCount(Main main, String recipeUserId) throws Exception { + Start start = (Start) StorageLayer.getStorage(main); + String tableName = Config.getConfig(start).getRecipeUserAccountInfosTable(); + + String QUERY = "SELECT COUNT(*) as cnt FROM " + tableName + + " WHERE app_id = ? AND recipe_user_id = ?"; + + return execute(start, QUERY, pst -> { + pst.setString(1, "public"); + pst.setString(2, recipeUserId); + }, rs -> { + rs.next(); + return rs.getInt("cnt"); }); } + // ======================== Backward-compatible public helpers ======================== + + /** + * Get all email reservations for a primary user in a tenant from primary_user_tenants table. + * Backward-compatible delegate to the generalized method. + */ + public static Set getAllPrimaryUserEmailReservations(Main main, TenantIdentifier tenant, + String primaryUserId) + throws Exception { + return getAllPrimaryUserReservations(main, tenant, primaryUserId, ACCOUNT_INFO_TYPE.EMAIL); + } + + /** + * Get recipe user email reservation for a specific user in a tenant from recipe_user_tenants table. + * Backward-compatible delegate. + */ + public static String getRecipeUserEmailReservation(Main main, TenantIdentifier tenant, String recipeUserId) + throws Exception { + Set emails = getRecipeUserReservations(main, tenant, recipeUserId, ACCOUNT_INFO_TYPE.EMAIL); + return emails.isEmpty() ? null : emails.iterator().next(); + } + + // ======================== Debug Utilities ======================== + /** * Print all reservations for a user for debugging purposes */ @@ -275,6 +540,8 @@ public static void printAllReservations(Main main, AuthRecipeUserInfo user) thro System.out.println("Login methods:"); for (LoginMethod lm : user.loginMethods) { System.out.println(" - " + lm.getSupertokensUserId() + ": email=" + lm.email + + ", phone=" + lm.phoneNumber + + ", thirdParty=" + (lm.thirdParty != null ? lm.thirdParty.id + "|" + lm.thirdParty.userId : "null") + ", tenants=" + lm.tenantIds); } @@ -283,14 +550,25 @@ public static void printAllReservations(Main main, AuthRecipeUserInfo user) thro System.out.println("\nTenant: " + tenantId); // Primary user reservations - Set primaryEmails = getAllPrimaryUserEmailReservations(main, tenant, primaryUserId); - System.out.println(" primary_user_tenants emails: " + primaryEmails); + if (user.isPrimaryUser) { + for (ACCOUNT_INFO_TYPE type : ACCOUNT_INFO_TYPE.values()) { + Set values = getAllPrimaryUserReservations(main, tenant, primaryUserId, type); + if (!values.isEmpty()) { + System.out.println(" primary_user_tenants " + type + ": " + values); + } + } + } // Recipe user reservations for each login method for (LoginMethod lm : user.loginMethods) { if (lm.tenantIds.contains(tenantId)) { - String recipeEmail = getRecipeUserEmailReservation(main, tenant, lm.getSupertokensUserId()); - System.out.println(" recipe_user_tenants for " + lm.getSupertokensUserId() + ": " + recipeEmail); + for (ACCOUNT_INFO_TYPE type : ACCOUNT_INFO_TYPE.values()) { + Set values = getRecipeUserReservations(main, tenant, lm.getSupertokensUserId(), type); + if (!values.isEmpty()) { + System.out.println(" recipe_user_tenants for " + lm.getSupertokensUserId() + + " " + type + ": " + values); + } + } } } } diff --git a/src/test/java/io/supertokens/storage/postgresql/test/ReservationTableIntegrityTest.java b/src/test/java/io/supertokens/storage/postgresql/test/ReservationTableIntegrityTest.java new file mode 100644 index 00000000..9a2b2618 --- /dev/null +++ b/src/test/java/io/supertokens/storage/postgresql/test/ReservationTableIntegrityTest.java @@ -0,0 +1,373 @@ +/* + * Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * 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 io.supertokens.storage.postgresql.test; + +import io.supertokens.ProcessState; +import io.supertokens.authRecipe.AuthRecipe; +import io.supertokens.emailpassword.EmailPassword; +import io.supertokens.featureflag.EE_FEATURES; +import io.supertokens.featureflag.FeatureFlagTestContent; +import io.supertokens.passwordless.Passwordless; +import io.supertokens.pluginInterface.STORAGE_TYPE; +import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; +import io.supertokens.pluginInterface.dashboard.DashboardSearchTags; +import io.supertokens.authRecipe.UserPaginationContainer; +import io.supertokens.storageLayer.StorageLayer; +import io.supertokens.thirdparty.ThirdParty; + +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestRule; + +import java.util.List; + +import static org.junit.Assert.*; + +/** + * Tests that verify reservation table integrity after key operations. + * Uses RaceTestUtils.checkReservationConsistency() to validate all invariants (I1-I6). + */ +public class ReservationTableIntegrityTest { + + @Rule + public TestRule watchman = Utils.getOnFailure(); + + @AfterClass + public static void afterTesting() { + Utils.afterTesting(); + } + + @Before + public void beforeEach() { + Utils.reset(); + } + + private TestingProcessManager.TestingProcess startProcess() throws Exception { + String[] args = {"../"}; + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args, false); + FeatureFlagTestContent.getInstance(process.getProcess()) + .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{ + EE_FEATURES.ACCOUNT_LINKING, EE_FEATURES.MULTI_TENANCY}); + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + return process; + } + + private Passwordless.ConsumeCodeResponse createPasswordlessUserWithEmail( + TestingProcessManager.TestingProcess process, String email) throws Exception { + Passwordless.CreateCodeResponse code = Passwordless.createCode(process.getProcess(), email, null, null, null); + return Passwordless.consumeCode(process.getProcess(), code.deviceId, code.deviceIdHash, + code.userInputCode, null); + } + + private Passwordless.ConsumeCodeResponse createPasswordlessUserWithPhone( + TestingProcessManager.TestingProcess process, String phone) throws Exception { + Passwordless.CreateCodeResponse code = Passwordless.createCode(process.getProcess(), null, phone, null, null); + return Passwordless.consumeCode(process.getProcess(), code.deviceId, code.deviceIdHash, + code.userInputCode, null); + } + + // ============================================================================ + // Test 1: Bug #3 regression — passwordless user with both email AND phone + // ============================================================================ + @Test + public void passwordlessUserWithBothEmailAndPhone_hasCorrectReservations() throws Exception { + TestingProcessManager.TestingProcess process = startProcess(); + try { + // Create passwordless user with email + Passwordless.ConsumeCodeResponse resp = createPasswordlessUserWithEmail(process, "pl@test.com"); + String userId = resp.user.getSupertokensUserId(); + + // Update to add phone number + Passwordless.updateUser(process.getProcess(), userId, + null, new Passwordless.FieldUpdate("+1234567890")); + + // Refetch and check + AuthRecipeUserInfo user = AuthRecipe.getUserById(process.getProcess(), userId); + assertNotNull(user); + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( + process.getProcess(), user); + assertTrue("Reservation inconsistency: " + result.issues, result.isConsistent); + } finally { + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + } + + // ============================================================================ + // Test 2: Bug #2 regression — time_joined consistency after linking + // ============================================================================ + @Test + public void afterLinkAccounts_timeJoinedIsConsistent() throws Exception { + TestingProcessManager.TestingProcess process = startProcess(); + try { + // Create primary (newer) and recipe user (older) + AuthRecipeUserInfo olderUser = EmailPassword.signUp(process.getProcess(), "older@test.com", "password123"); + Thread.sleep(50); // Ensure different timestamps + AuthRecipeUserInfo newerUser = EmailPassword.signUp(process.getProcess(), "newer@test.com", "password123"); + + // Make newer user primary, link older user + AuthRecipe.createPrimaryUser(process.getProcess(), newerUser.getSupertokensUserId()); + AuthRecipe.linkAccounts(process.getProcess(), olderUser.getSupertokensUserId(), + newerUser.getSupertokensUserId()); + + // Refetch and check — primary_or_recipe_user_time_joined should be MIN + AuthRecipeUserInfo user = AuthRecipe.getUserById(process.getProcess(), newerUser.getSupertokensUserId()); + assertNotNull(user); + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( + process.getProcess(), user); + assertTrue("Reservation inconsistency: " + result.issues, result.isConsistent); + } finally { + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + } + + // ============================================================================ + // Test 3: After unlink, primary_user_tenants cleaned up for unlinked user + // ============================================================================ + @Test + public void afterUnlinkAccounts_primaryUserTenantsCleanedUp() throws Exception { + TestingProcessManager.TestingProcess process = startProcess(); + try { + AuthRecipeUserInfo primary = EmailPassword.signUp(process.getProcess(), "primary@test.com", "password123"); + AuthRecipeUserInfo recipe = EmailPassword.signUp(process.getProcess(), "recipe@test.com", "password123"); + + AuthRecipe.createPrimaryUser(process.getProcess(), primary.getSupertokensUserId()); + AuthRecipe.linkAccounts(process.getProcess(), recipe.getSupertokensUserId(), + primary.getSupertokensUserId()); + + // Unlink + AuthRecipe.unlinkAccounts(process.getProcess(), recipe.getSupertokensUserId()); + + // Check primary user (should no longer have recipe's email in primary_user_tenants) + AuthRecipeUserInfo primaryAfter = AuthRecipe.getUserById(process.getProcess(), + primary.getSupertokensUserId()); + assertNotNull(primaryAfter); + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( + process.getProcess(), primaryAfter); + assertTrue("Primary user inconsistency after unlink: " + result.issues, result.isConsistent); + + // Check unlinked user + AuthRecipeUserInfo unlinked = AuthRecipe.getUserById(process.getProcess(), + recipe.getSupertokensUserId()); + assertNotNull(unlinked); + RaceTestUtils.ConsistencyCheckResult result2 = RaceTestUtils.checkReservationConsistency( + process.getProcess(), unlinked); + assertTrue("Unlinked user inconsistency: " + result2.issues, result2.isConsistent); + } finally { + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + } + + // ============================================================================ + // Test 4: Primary with no linked users keeps own reservations + // ============================================================================ + @Test + public void afterUnlinkLastUser_primaryKeepsOwnReservations() throws Exception { + TestingProcessManager.TestingProcess process = startProcess(); + try { + AuthRecipeUserInfo primary = EmailPassword.signUp(process.getProcess(), "primary@test.com", "password123"); + AuthRecipeUserInfo recipe = EmailPassword.signUp(process.getProcess(), "recipe@test.com", "password123"); + + AuthRecipe.createPrimaryUser(process.getProcess(), primary.getSupertokensUserId()); + AuthRecipe.linkAccounts(process.getProcess(), recipe.getSupertokensUserId(), + primary.getSupertokensUserId()); + + // Unlink + AuthRecipe.unlinkAccounts(process.getProcess(), recipe.getSupertokensUserId()); + + // Primary should still be a primary user with its own email in primary_user_tenants + AuthRecipeUserInfo primaryAfter = AuthRecipe.getUserById(process.getProcess(), + primary.getSupertokensUserId()); + assertNotNull(primaryAfter); + assertTrue(primaryAfter.isPrimaryUser); + assertEquals(1, primaryAfter.loginMethods.length); + assertEquals("primary@test.com", primaryAfter.loginMethods[0].email); + + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( + process.getProcess(), primaryAfter); + assertTrue("Primary should keep own reservations: " + result.issues, result.isConsistent); + } finally { + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + } + + // ============================================================================ + // Test 5: Bug #4 regression — phone+provider search returns empty + // ============================================================================ + @Test + public void dashboardSearchWithPhoneAndProvider_returnsEmpty() throws Exception { + TestingProcessManager.TestingProcess process = startProcess(); + try { + // Create a passwordless user with phone + createPasswordlessUserWithPhone(process, "+1234567890"); + + // Create a third-party user + ThirdParty.signInUp(process.getProcess(), "google", "g-user-1", "tp@test.com"); + + // Search with phone + provider — should return empty (no user matches both) + DashboardSearchTags tags = new DashboardSearchTags(null, List.of("+123"), List.of("google")); + UserPaginationContainer result = AuthRecipe.getUsers(process.getProcess(), 10, "ASC", + null, null, tags); + assertEquals("phone+provider search should return 0 results", 0, result.users.length); + } finally { + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + } + + // ============================================================================ + // Test 6: Bug #4 regression — email+phone+provider search returns empty + // ============================================================================ + @Test + public void dashboardSearchWithAllThreeTags_returnsEmpty() throws Exception { + TestingProcessManager.TestingProcess process = startProcess(); + try { + // Create users with various account info + EmailPassword.signUp(process.getProcess(), "ep@test.com", "password123"); + createPasswordlessUserWithPhone(process, "+1234567890"); + ThirdParty.signInUp(process.getProcess(), "google", "g-user-1", "tp@test.com"); + + // Search with all three tags — no single recipe supports all three + DashboardSearchTags tags = new DashboardSearchTags(List.of("test"), List.of("+123"), List.of("google")); + UserPaginationContainer result = AuthRecipe.getUsers(process.getProcess(), 10, "ASC", + null, null, tags); + assertEquals("email+phone+provider search should return 0 results", 0, result.users.length); + } finally { + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + } + + // ============================================================================ + // Test 7: Passwordless user with email only + // ============================================================================ + @Test + public void passwordlessUserWithEmailOnly_hasCorrectReservations() throws Exception { + TestingProcessManager.TestingProcess process = startProcess(); + try { + Passwordless.ConsumeCodeResponse resp = createPasswordlessUserWithEmail(process, "emailonly@test.com"); + + AuthRecipeUserInfo user = AuthRecipe.getUserById(process.getProcess(), + resp.user.getSupertokensUserId()); + assertNotNull(user); + + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( + process.getProcess(), user); + assertTrue("Single email PL user inconsistency: " + result.issues, result.isConsistent); + } finally { + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + } + + // ============================================================================ + // Test 8: Third-party user has THIRD_PARTY type in reservations + // ============================================================================ + @Test + public void thirdPartyUser_reservationsIncludeThirdPartyType() throws Exception { + TestingProcessManager.TestingProcess process = startProcess(); + try { + ThirdParty.SignInUpResponse resp = ThirdParty.signInUp( + process.getProcess(), "google", "g-user-123", "tp@test.com"); + + AuthRecipeUserInfo user = AuthRecipe.getUserById(process.getProcess(), + resp.user.getSupertokensUserId()); + assertNotNull(user); + + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( + process.getProcess(), user); + assertTrue("ThirdParty user inconsistency: " + result.issues, result.isConsistent); + } finally { + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + } + + // ============================================================================ + // Test 9: Cross-recipe linking — EP email + PL phone both in primary_user_tenants + // ============================================================================ + @Test + public void linkAccountsAcrossRecipeTypes_allReservationsPresent() throws Exception { + TestingProcessManager.TestingProcess process = startProcess(); + try { + // EmailPassword user with email + AuthRecipeUserInfo epUser = EmailPassword.signUp(process.getProcess(), "ep@test.com", "password123"); + AuthRecipe.createPrimaryUser(process.getProcess(), epUser.getSupertokensUserId()); + + // Passwordless user with phone + Passwordless.ConsumeCodeResponse plResp = createPasswordlessUserWithPhone(process, "+1234567890"); + String plUserId = plResp.user.getSupertokensUserId(); + + // Link PL to EP + AuthRecipe.linkAccounts(process.getProcess(), plUserId, epUser.getSupertokensUserId()); + + // Refetch primary user + AuthRecipeUserInfo linkedUser = AuthRecipe.getUserById(process.getProcess(), + epUser.getSupertokensUserId()); + assertNotNull(linkedUser); + assertTrue(linkedUser.isPrimaryUser); + assertEquals(2, linkedUser.loginMethods.length); + + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( + process.getProcess(), linkedUser); + assertTrue("Cross-recipe link inconsistency: " + result.issues, result.isConsistent); + } finally { + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + } + + // ============================================================================ + // Test 10: After clearing email, phone reservation preserved + // ============================================================================ + @Test + public void passwordlessClearEmail_phoneReservationPreserved() throws Exception { + TestingProcessManager.TestingProcess process = startProcess(); + try { + // Create PL user with email + Passwordless.ConsumeCodeResponse resp = createPasswordlessUserWithEmail(process, "clear@test.com"); + String userId = resp.user.getSupertokensUserId(); + + // Add phone + Passwordless.updateUser(process.getProcess(), userId, + null, new Passwordless.FieldUpdate("+1234567890")); + + // Clear email + Passwordless.updateUser(process.getProcess(), userId, + new Passwordless.FieldUpdate(null), null); + + // Refetch + AuthRecipeUserInfo user = AuthRecipe.getUserById(process.getProcess(), userId); + assertNotNull(user); + assertNull(user.loginMethods[0].email); + assertEquals("+1234567890", user.loginMethods[0].phoneNumber); + + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( + process.getProcess(), user); + assertTrue("After clearing email, phone should remain: " + result.issues, result.isConsistent); + } finally { + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + } +} diff --git a/src/test/java/io/supertokens/storage/postgresql/test/SuperTokensSaaSSecretTest.java b/src/test/java/io/supertokens/storage/postgresql/test/SuperTokensSaaSSecretTest.java index eb135240..e40a2a3f 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/SuperTokensSaaSSecretTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/SuperTokensSaaSSecretTest.java @@ -49,7 +49,8 @@ public class SuperTokensSaaSSecretTest { "postgresql_minimum_idle_connections", "postgresql_idle_connection_timeout"}; private final Object[] PROTECTED_DB_CONFIG_VALUES = new Object[]{11, - "postgresql://root:root@localhost:5432/supertokens?currentSchema=myschema", "localhost", 5432, "root", + "postgresql://root:root@" + DatabaseTestHelper.getHost() + ":" + DatabaseTestHelper.getPort() + "/supertokens?currentSchema=myschema", + DatabaseTestHelper.getHost(), Integer.parseInt(DatabaseTestHelper.getPort()), "root", "root", "supertokens", "myschema", 5, 120000}; @Rule diff --git a/src/test/java/io/supertokens/storage/postgresql/test/TestingProcessManager.java b/src/test/java/io/supertokens/storage/postgresql/test/TestingProcessManager.java index 7c82f52f..20e39504 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/TestingProcessManager.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/TestingProcessManager.java @@ -41,10 +41,14 @@ static void deleteAllInformation() throws Exception { } static void killAll() { + killAll(true); + } + + static void killAll(boolean removeAllInfo) { synchronized (alive) { for (TestingProcess testingProcess : alive) { try { - testingProcess.kill(); + testingProcess.kill(removeAllInfo); } catch (InterruptedException e) { } } diff --git a/src/test/java/io/supertokens/storage/postgresql/test/ThirdPartyRaceTest.java b/src/test/java/io/supertokens/storage/postgresql/test/ThirdPartyRaceTest.java index f59711d1..561fcf62 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/ThirdPartyRaceTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/ThirdPartyRaceTest.java @@ -153,7 +153,7 @@ public void testLinkDuringThirdPartySignInUpEmailUpdate() throws Exception { if (isLinked) { // CRITICAL: Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( process.getProcess(), finalUser); if (!result.isConsistent) { @@ -255,7 +255,7 @@ public void testThirdPartyPreTransactionQueryRace() throws Exception { if (finalUser != null) { // CRITICAL: Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( process.getProcess(), finalUser); if (!result.isConsistent) { @@ -341,7 +341,7 @@ public void testThirdPartySignInUpWithUnlink() throws Exception { if (isLinked) { // CRITICAL: Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( process.getProcess(), finalUser); if (!result.isConsistent) { @@ -458,7 +458,7 @@ public void testRapidThirdPartyEmailChanges() throws Exception { if (isLinked) { // CRITICAL: Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( process.getProcess(), finalUser); if (!result.isConsistent) { @@ -562,7 +562,7 @@ public void testThirdPartyNewUserCreationDuringLinking() throws Exception { if (isLinked) { // CRITICAL: Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( process.getProcess(), finalRecipeUser); if (!result.isConsistent) { diff --git a/src/test/java/io/supertokens/storage/postgresql/test/UnlinkRaceTest.java b/src/test/java/io/supertokens/storage/postgresql/test/UnlinkRaceTest.java index 4221670d..39e7fbe5 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/UnlinkRaceTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/UnlinkRaceTest.java @@ -172,7 +172,7 @@ public void testUnlinkDuringEmailUpdate() throws Exception { } else { // User is still linked - verify consistency using reservation tables if (userEmail != null) { - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( process.getProcess(), finalUser); if (!result.isConsistent) { @@ -657,7 +657,7 @@ public void testRapidUnlinkLinkWithEmailUpdates() throws Exception { if (isLinked) { // CRITICAL: Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( process.getProcess(), finalUser); if (!result.isConsistent) { diff --git a/src/test/java/io/supertokens/storage/postgresql/test/Utils.java b/src/test/java/io/supertokens/storage/postgresql/test/Utils.java index f2477138..f48458a0 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/Utils.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/Utils.java @@ -36,15 +36,28 @@ import java.nio.file.Paths; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.Random; public abstract class Utils extends Mockito { private static ByteArrayOutputStream byteArrayOutputStream; + // Track the current test database for cleanup + private static final ThreadLocal currentTestDatabaseName = new ThreadLocal<>(); + public static void afterTesting() { String installDir = "../"; try { + // Kill any remaining SuperTokens processes so the test JVM can exit. + // Without this, the last test's instance stays alive (non-daemon threads + // in the webserver, cron jobs, and connection pools prevent JVM shutdown). + TestingProcessManager.killAll(false); + StorageLayer.close(); + + // Clean up the test database reference + currentTestDatabaseName.remove(); + // we remove the license key file ProcessBuilder pb = new ProcessBuilder("rm", "licenseKey"); pb.directory(new File(installDir)); @@ -58,12 +71,11 @@ public static void afterTesting() { process = pb.start(); process.waitFor(); - // remove webserver-temp folders created by tomcat - final File webserverTemp = new File(installDir + "webserver-temp"); - try { - FileUtils.deleteDirectory(webserverTemp); - } catch (Exception ignored) { - } + // Note: We don't delete webserver-temp here because: + // 1. Each Webserver creates its own UUID subdirectory (webserver-temp/UUID/) + // 2. Each Webserver cleans up its own subdirectory in stop() + // 3. Deleting the entire webserver-temp folder causes cross-worker conflicts + // when tests run in parallel (one worker's cleanup deletes another's temp dir) // remove .started folder created by processes final File dotStartedFolder = new File(installDir + ".started" + workerId); @@ -87,26 +99,33 @@ public static void reset() { String installDir = "../"; String workerId = System.getProperty("org.gradle.test.worker"); try { - // if the default config is not the same as the current config, we must reset - // the storage layer - File ogConfig = new File("../temp/config.yaml"); - File currentConfig = new File("../config" + workerId + ".yaml"); - if (currentConfig.isFile()) { - byte[] ogConfigContent = Files.readAllBytes(ogConfig.toPath()); - byte[] currentConfigContent = Files.readAllBytes(currentConfig.toPath()); - if (!Arrays.equals(ogConfigContent, currentConfigContent)) { - StorageLayer.close(); - } - } + // Kill all processes WITHOUT dropping tables — TRUNCATE will handle data cleanup. + // This preserves the schema so the next process startup's CREATE TABLE IF NOT EXISTS + // are all no-ops, saving ~88 DDL statements per test. + TestingProcessManager.killAll(false); + + // Close the storage layer (releases HikariCP pools etc.) + StorageLayer.close(); + + // Get or create the per-worker test database (created once, reused across tests) + String testDbName = DatabaseTestHelper.createTestDatabase(); + currentTestDatabaseName.set(testDbName); + // Truncate all data in the test database (keeps tables intact for fast re-use) + DatabaseTestHelper.truncateAllData(); + + // Copy base config file (tests may have modified it) ProcessBuilder pb = new ProcessBuilder("cp", "temp/config.yaml", "./config" + workerId + ".yaml"); pb.directory(new File(installDir)); Process process = pb.start(); process.waitFor(); - TestingProcessManager.killAll(); - TestingProcessManager.deleteAllInformation(); - TestingProcessManager.killAll(); + // Update config with test-specific database name and connection details + setValueInConfigDirectly("postgresql_database_name", "\"" + testDbName + "\"", workerId); + setValueInConfigDirectly("postgresql_host", "\"" + DatabaseTestHelper.getHost() + "\"", workerId); + setValueInConfigDirectly("postgresql_port", DatabaseTestHelper.getPort(), workerId); + setValueInConfigDirectly("postgresql_user", "\"" + DatabaseTestHelper.getUser() + "\"", workerId); + setValueInConfigDirectly("postgresql_password", "\"" + DatabaseTestHelper.getPassword() + "\"", workerId); byteArrayOutputStream = new ByteArrayOutputStream(); System.setErr(new PrintStream(byteArrayOutputStream)); @@ -116,6 +135,27 @@ public static void reset() { System.gc(); } + /** + * Internal method to set a config value without closing StorageLayer. + * Used during reset() to configure the test database. + */ + private static void setValueInConfigDirectly(String key, String value, String workerId) throws IOException { + String oldStr = "\n((#\\s)?)" + key + "(:|((:\\s).+))\n"; + String newStr = "\n" + key + ": " + value + "\n"; + StringBuilder originalFileContent = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new FileReader("../config" + workerId + ".yaml"))) { + String currentReadingLine = reader.readLine(); + while (currentReadingLine != null) { + originalFileContent.append(currentReadingLine).append(System.lineSeparator()); + currentReadingLine = reader.readLine(); + } + String modifiedFileContent = originalFileContent.toString().replaceAll(oldStr, newStr); + try (BufferedWriter writer = new BufferedWriter(new FileWriter("../config" + workerId + ".yaml"))) { + writer.write(modifiedFileContent); + } + } + } + static void stopLicenseKeyFromUpdatingToLatest(TestingProcessManager.TestingProcess process) { try { List licenseKey = Files.readAllLines(Paths.get("../licenseKey")); @@ -184,13 +224,45 @@ public static void commentConfigValue(String key) throws IOException { public static TestRule getOnFailure() { return new TestWatcher() { + private Map> pgStatBefore; + + @Override + protected void starting(Description description) { + pgStatBefore = DatabaseTestHelper.takePgStatMonitorSnapshot( + DatabaseTestHelper.getCurrentTestDatabase()); + } + @Override protected void failed(Throwable e, Description description) { System.out.println(byteArrayOutputStream.toString(StandardCharsets.UTF_8)); } + + @Override + protected void finished(Description description) { + String testName = description.getClassName() + "." + description.getMethodName(); + DatabaseTestHelper.collectPgStatMonitorData( + DatabaseTestHelper.getCurrentTestDatabase(), testName, pgStatBefore); + } }; } + /** + * Checks if file logging is enabled (i.e., log paths are not set to "null" via environment variables). + * When INFO_LOG_PATH or ERROR_LOG_PATH envvars are set to "null", logging goes to console instead of files. + * + * @return true if file logging is enabled, false if logging is configured to go to console + */ + public static boolean isFileLoggingEnabled() { + String infoLogPath = System.getenv("INFO_LOG_PATH"); + String errorLogPath = System.getenv("ERROR_LOG_PATH"); + + // If either envvar is set to "null" (case-insensitive), file logging is disabled + boolean infoLogNull = infoLogPath != null && infoLogPath.equalsIgnoreCase("null"); + boolean errorLogNull = errorLogPath != null && errorLogPath.equalsIgnoreCase("null"); + + return !infoLogNull && !errorLogNull; + } + public static TestRule retryFlakyTest() { return new TestRule() { private final int retryCount = 2; diff --git a/src/test/java/io/supertokens/storage/postgresql/test/WebAuthnRaceTest.java b/src/test/java/io/supertokens/storage/postgresql/test/WebAuthnRaceTest.java index 011a11d7..7fa08ef0 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/WebAuthnRaceTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/WebAuthnRaceTest.java @@ -161,7 +161,7 @@ public void testLinkDuringEmailUpdate() throws Exception { if (isLinked && actualEmail != null) { // CRITICAL: Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( main, finalUser); if (!result.isConsistent) { @@ -373,7 +373,7 @@ public void testHighConcurrencyEmailUpdatesWithLinking() throws Exception { if (actualEmail != null) { // CRITICAL: Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( main, finalUser); if (!result.isConsistent) { @@ -504,7 +504,7 @@ public void testRapidEmailUpdatesWithLinking() throws Exception { if (actualEmail != null) { // CRITICAL: Check reservation tables directly via SQL - RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkEmailReservationConsistency( + RaceTestUtils.ConsistencyCheckResult result = RaceTestUtils.checkReservationConsistency( main, finalUser); if (!result.isConsistent) { diff --git a/src/test/java/io/supertokens/storage/postgresql/test/multitenancy/StorageLayerTest.java b/src/test/java/io/supertokens/storage/postgresql/test/multitenancy/StorageLayerTest.java index dc592a21..14d23710 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/multitenancy/StorageLayerTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/multitenancy/StorageLayerTest.java @@ -41,6 +41,7 @@ import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.storage.postgresql.Start; import io.supertokens.storage.postgresql.test.TestingProcessManager; +import io.supertokens.storage.postgresql.test.DatabaseTestHelper; import io.supertokens.storage.postgresql.test.Utils; import io.supertokens.storageLayer.StorageLayer; import io.supertokens.thirdparty.InvalidProviderConfigException; @@ -703,15 +704,16 @@ public void testCreating50StorageLayersUsage() for (int i = 0; i < 50; i++) { final int insideLoop = i; executor.submit(() -> { - JsonObject config = new JsonObject(); - config.addProperty("postgresql_database_name", "st" + insideLoop); - tenants[insideLoop] = new TenantConfig(new TenantIdentifier(null, "a" + insideLoop, null), - new EmailPasswordConfig(false), - new ThirdPartyConfig(false, new ThirdPartyConfig.Provider[0]), - new PasswordlessConfig(false), - null, null, - config); try { + JsonObject config = new JsonObject(); + StorageLayer.getStorage(new TenantIdentifier(null, null, null), process.getProcess()) + .modifyConfigToAddANewUserPoolForTesting(config, insideLoop); + tenants[insideLoop] = new TenantConfig(new TenantIdentifier(null, "a" + insideLoop, null), + new EmailPasswordConfig(false), + new ThirdPartyConfig(false, new ThirdPartyConfig.Provider[0]), + new PasswordlessConfig(false), + null, null, + config); Multitenancy.addNewOrUpdateAppOrTenant(process.getProcess(), new TenantIdentifier(null, null, null), tenants[insideLoop]); } catch (Exception e) { @@ -757,7 +759,7 @@ public void testCantCreateTenantWithUnknownDb() JsonObject tenantConfigJson = new JsonObject(); tenantConfigJson.add("postgresql_connection_uri", - new JsonPrimitive("postgresql://root:root@localhost:5432/random")); + new JsonPrimitive("postgresql://root:root@" + DatabaseTestHelper.getHost() + ":" + DatabaseTestHelper.getPort() + "/random")); TenantConfig tenantConfig = new TenantConfig(new TenantIdentifier("abc", null, null), new EmailPasswordConfig(false), @@ -797,7 +799,7 @@ public void testTenantCreationAndThenDbDownDbThrowsErrorInRecipesAndDoesntAffect JsonObject tenantConfigJson = new JsonObject(); tenantConfigJson.add("postgresql_connection_uri", - new JsonPrimitive("postgresql://root:root@localhost:5432/random")); + new JsonPrimitive("postgresql://root:root@" + DatabaseTestHelper.getHost() + ":" + DatabaseTestHelper.getPort() + "/random")); TenantIdentifier tid = new TenantIdentifier("abc", null, null); @@ -897,7 +899,7 @@ public void testBadPortWithNewTenantShouldNotCauseItToWaitInput() throws Excepti } catch (StorageQueryException e) { assertEquals(e.getMessage(), "java.sql.SQLException: com.zaxxer.hikari.pool.HikariPool$PoolInitializationException: Failed to " + - "initialize pool: Connection to localhost:8989 refused. Check that the hostname and port " + + "initialize pool: Connection to " + DatabaseTestHelper.getHost() + ":8989 refused. Check that the hostname and port " + "are correct and that the postmaster is accepting TCP/IP connections."); } diff --git a/src/test/java/io/supertokens/storage/postgresql/test/multitenancy/TestForNoCrashDuringStartup.java b/src/test/java/io/supertokens/storage/postgresql/test/multitenancy/TestForNoCrashDuringStartup.java index fc340727..e7033d62 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/multitenancy/TestForNoCrashDuringStartup.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/multitenancy/TestForNoCrashDuringStartup.java @@ -35,6 +35,7 @@ import io.supertokens.storage.postgresql.Start; import io.supertokens.storage.postgresql.queries.MultitenancyQueries; import io.supertokens.storage.postgresql.test.TestingProcessManager; +import io.supertokens.storage.postgresql.test.DatabaseTestHelper; import io.supertokens.storage.postgresql.test.Utils; import io.supertokens.storage.postgresql.test.httpRequest.HttpRequestForTesting; import io.supertokens.storage.postgresql.test.httpRequest.HttpResponseException; @@ -125,18 +126,19 @@ public void testThatCUDRecoversWhenItFailsToAddEntryDuringCreation() throws Exce @Test public void testThatCUDRecoversWhenTheDbIsDownDuringCreationButDbComesUpLater() throws Exception { + JsonObject coreConfig = new JsonObject(); + StorageLayer.getStorage(new TenantIdentifier(null, null, null), process.getProcess()) + .modifyConfigToAddANewUserPoolForTesting(coreConfig, 5000); + String dbName = coreConfig.get("postgresql_database_name").getAsString(); + Start start = ((Start) StorageLayer.getBaseStorage(process.getProcess())); try { - update(start, "DROP DATABASE st5000;", pst -> { + update(start, "DROP DATABASE " + dbName + ";", pst -> { }); } catch (Exception e) { // ignore } - JsonObject coreConfig = new JsonObject(); - StorageLayer.getStorage(new TenantIdentifier(null, null, null), process.getProcess()) - .modifyConfigToAddANewUserPoolForTesting(coreConfig, 5000); - TenantIdentifier tenantIdentifier = new TenantIdentifier("127.0.0.1", null, null); try { @@ -153,7 +155,7 @@ public void testThatCUDRecoversWhenTheDbIsDownDuringCreationButDbComesUpLater() // ignore assertEquals( "java.sql.SQLException: com.zaxxer.hikari.pool.HikariPool$PoolInitializationException: Failed to " + - "initialize pool: FATAL: database \"st5000\" does not exist", + "initialize pool: FATAL: database \"" + dbName + "\" does not exist", e.getMessage()); } @@ -166,10 +168,10 @@ public void testThatCUDRecoversWhenTheDbIsDownDuringCreationButDbComesUpLater() fail(); } catch (HttpResponseException e) { // ignore - assertEquals("Http error. Status Code: 500. Message: io.supertokens.pluginInterface.exceptions.StorageQueryException: java.sql.SQLException: com.zaxxer.hikari.pool.HikariPool$PoolInitializationException: Failed to initialize pool: FATAL: database \"st5000\" does not exist", e.getMessage()); + assertEquals("Http error. Status Code: 500. Message: io.supertokens.pluginInterface.exceptions.StorageQueryException: java.sql.SQLException: com.zaxxer.hikari.pool.HikariPool$PoolInitializationException: Failed to initialize pool: FATAL: database \"" + dbName + "\" does not exist", e.getMessage()); } - update(start, "CREATE DATABASE st5000;", pst -> { + update(start, "CREATE DATABASE " + dbName + ";", pst -> { }); // this should succeed now @@ -506,18 +508,19 @@ public void testThatCoreDoesNotCrashDuringStartupWhenCUDCreationFailedToAddTenan @Test public void testThatTenantComesToLifeOnceTheTargetDbIsUpAfterCoreRestart() throws Exception { + JsonObject coreConfig = new JsonObject(); + StorageLayer.getStorage(new TenantIdentifier(null, null, null), process.getProcess()) + .modifyConfigToAddANewUserPoolForTesting(coreConfig, 5000); + String dbName = coreConfig.get("postgresql_database_name").getAsString(); + Start start = ((Start) StorageLayer.getBaseStorage(process.getProcess())); try { - update(start, "DROP DATABASE st5000;", pst -> { + update(start, "DROP DATABASE " + dbName + ";", pst -> { }); } catch (Exception e) { // ignore } - JsonObject coreConfig = new JsonObject(); - StorageLayer.getStorage(new TenantIdentifier(null, null, null), process.getProcess()) - .modifyConfigToAddANewUserPoolForTesting(coreConfig, 5000); - TenantIdentifier tenantIdentifier = new TenantIdentifier("127.0.0.1", null, null); try { @@ -534,7 +537,7 @@ public void testThatTenantComesToLifeOnceTheTargetDbIsUpAfterCoreRestart() throw // ignore assertEquals( "java.sql.SQLException: com.zaxxer.hikari.pool.HikariPool$PoolInitializationException: Failed to " + - "initialize pool: FATAL: database \"st5000\" does not exist", + "initialize pool: FATAL: database \"" + dbName + "\" does not exist", e.getMessage()); } @@ -547,7 +550,7 @@ public void testThatTenantComesToLifeOnceTheTargetDbIsUpAfterCoreRestart() throw fail(); } catch (HttpResponseException e) { // ignore - assertEquals("Http error. Status Code: 500. Message: io.supertokens.pluginInterface.exceptions.StorageQueryException: java.sql.SQLException: com.zaxxer.hikari.pool.HikariPool$PoolInitializationException: Failed to initialize pool: FATAL: database \"st5000\" does not exist", e.getMessage()); + assertEquals("Http error. Status Code: 500. Message: io.supertokens.pluginInterface.exceptions.StorageQueryException: java.sql.SQLException: com.zaxxer.hikari.pool.HikariPool$PoolInitializationException: Failed to initialize pool: FATAL: database \"" + dbName + "\" does not exist", e.getMessage()); } process.kill(false); @@ -562,7 +565,7 @@ public void testThatTenantComesToLifeOnceTheTargetDbIsUpAfterCoreRestart() throw // the process should start successfully even though the db is down start = ((Start) StorageLayer.getBaseStorage(process.getProcess())); - update(start, "CREATE DATABASE st5000;", pst -> { + update(start, "CREATE DATABASE " + dbName + ";", pst -> { }); // this should succeed now diff --git a/src/test/java/io/supertokens/storage/postgresql/test/multitenancy/TestUserPoolIdChangeBehaviour.java b/src/test/java/io/supertokens/storage/postgresql/test/multitenancy/TestUserPoolIdChangeBehaviour.java index 758e749a..0f390440 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/multitenancy/TestUserPoolIdChangeBehaviour.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/multitenancy/TestUserPoolIdChangeBehaviour.java @@ -32,6 +32,7 @@ import io.supertokens.pluginInterface.multitenancy.*; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.storage.postgresql.test.TestingProcessManager; +import io.supertokens.storage.postgresql.test.DatabaseTestHelper; import io.supertokens.storage.postgresql.test.Utils; import io.supertokens.storageLayer.StorageLayer; import io.supertokens.thirdparty.InvalidProviderConfigException; @@ -41,6 +42,7 @@ import org.junit.Test; import java.io.IOException; +import java.net.InetAddress; import static org.junit.Assert.*; @@ -96,7 +98,8 @@ public void testUsersWorkAfterUserPoolIdChanges() throws Exception { AuthRecipeUserInfo userInfo = EmailPassword.signUp( tenantIdentifier, storage, process.getProcess(), "user@example.com", "password"); - coreConfig.addProperty("postgresql_host", "127.0.0.1"); + coreConfig.addProperty("postgresql_host", + InetAddress.getByName(DatabaseTestHelper.getHost()).getHostAddress()); Multitenancy.addNewOrUpdateAppOrTenant(process.getProcess(), new TenantConfig( tenantIdentifier, new EmailPasswordConfig(true), @@ -143,7 +146,8 @@ public void testUsersWorkAfterUserPoolIdChangesAndServerRestart() throws Excepti AuthRecipeUserInfo userInfo = EmailPassword.signUp( tenantIdentifier, storage, process.getProcess(), "user@example.com", "password"); - coreConfig.addProperty("postgresql_host", "127.0.0.1"); + coreConfig.addProperty("postgresql_host", + InetAddress.getByName(DatabaseTestHelper.getHost()).getHostAddress()); Multitenancy.addNewOrUpdateAppOrTenant(process.getProcess(), new TenantConfig( tenantIdentifier, new EmailPasswordConfig(true),