From 109af397ae16f0926a39ad6ee7bfefd91195cc3a Mon Sep 17 00:00:00 2001 From: Mihaly Date: Fri, 6 Feb 2026 12:05:39 +0100 Subject: [PATCH 01/23] test: compatibility fixes with docker based test run with pre-started pg/oauth containers --- implementationDependencies.json | 20 +- .../storage/postgresql/ConnectionPool.java | 3 +- .../supertokens/storage/postgresql/Start.java | 76 +++++- .../postgresql/config/PostgreSQLConfig.java | 2 +- .../storage/postgresql/test/ConfigTest.java | 87 +++++-- .../postgresql/test/DatabaseTestHelper.java | 224 ++++++++++++++++++ .../storage/postgresql/test/LoggingTest.java | 7 + .../storage/postgresql/test/Utils.java | 83 ++++++- 8 files changed, 462 insertions(+), 40 deletions(-) create mode 100644 src/test/java/io/supertokens/storage/postgresql/test/DatabaseTestHelper.java diff --git a/implementationDependencies.json b/implementationDependencies.json index 4ea97757..f6631891 100644 --- a/implementationDependencies.json +++ b/implementationDependencies.json @@ -6,16 +6,26 @@ "name":"jackson-dataformat-yaml 2.18.2", "src":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.18.2/jackson-dataformat-yaml-2.18.2-sources.jar" }, - { - "jar":"https://repo.maven.apache.org/maven2/org/yaml/snakeyaml/2.3/snakeyaml-2.3.jar", - "name":"snakeyaml 2.3", - "src":"https://repo.maven.apache.org/maven2/org/yaml/snakeyaml/2.3/snakeyaml-2.3-sources.jar" - }, { "jar":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/core/jackson-databind/2.18.2/jackson-databind-2.18.2.jar", "name":"jackson-databind 2.18.2", "src":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/core/jackson-databind/2.18.2/jackson-databind-2.18.2-sources.jar" }, + { + "jar":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/core/jackson-core/2.18.2/jackson-core-2.18.2.jar", + "name":"jackson-core 2.18.2", + "src":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/core/jackson-core/2.18.2/jackson-core-2.18.2-sources.jar" + }, + { + "jar":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/core/jackson-annotations/2.18.2/jackson-annotations-2.18.2.jar", + "name":"jackson-annotations 2.18.2", + "src":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/core/jackson-annotations/2.18.2/jackson-annotations-2.18.2-sources.jar" + }, + { + "jar":"https://repo.maven.apache.org/maven2/org/yaml/snakeyaml/2.3/snakeyaml-2.3.jar", + "name":"snakeyaml 2.3", + "src":"https://repo.maven.apache.org/maven2/org/yaml/snakeyaml/2.3/snakeyaml-2.3-sources.jar" + }, { "jar":"https://repo.maven.apache.org/maven2/ch/qos/logback/logback-classic/1.5.13/logback-classic-1.5.13.jar", "name":"logback-classic 1.5.13", 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 abf6cdce..71e42c3d 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; @@ -1089,7 +1091,79 @@ public void addInfoToNonAuthRecipesBasedOnUserId(TenantIdentifier tenantIdentifi @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 (1-10). + // Higher pool numbers (like 1000) are used in tests that expect the database + // NOT to exist (for testing error handling). + if (poolNumber >= 1 && poolNumber <= 10) { + ensureTestDatabaseExists(dbName, config); + } + + 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()) { + + // Drop existing database for clean state (important for test isolation) + try { + stmt.executeUpdate("DROP DATABASE IF EXISTS " + dbName); + } catch (SQLException ignored) { + // Ignore errors - database might have active 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 e289f766..07728f73 100644 --- a/src/main/java/io/supertokens/storage/postgresql/config/PostgreSQLConfig.java +++ b/src/main/java/io/supertokens/storage/postgresql/config/PostgreSQLConfig.java @@ -674,7 +674,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/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..cff205e5 --- /dev/null +++ b/src/test/java/io/supertokens/storage/postgresql/test/DatabaseTestHelper.java @@ -0,0 +1,224 @@ +/* + * 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.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Helper class for managing test-specific PostgreSQL databases. + * Each test gets its own isolated database to prevent interference. + */ +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<>(); + + // 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(); + } + + /** + * Create a new test database. + * Connects to the admin database to create the test database. + */ + public static String createTestDatabase() { + 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); + // System.out.println("[DatabaseTestHelper] Created test database: " + dbName); + + 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); + } + } + + /** + * Drop the current test database. + * Should be called after the test completes. + */ + public static void dropCurrentTestDatabase() { + String dbName = currentTestDatabase.get(); + if (dbName == null) { + return; + } + + dropTestDatabase(dbName); + 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()); + } + } + + /** + * 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; + } +} 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..53abfbdc 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 = {"../"}; 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 7d13cc27..bdc985a1 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/Utils.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/Utils.java @@ -42,9 +42,19 @@ 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 { + // Drop the test-specific database + String testDb = currentTestDatabaseName.get(); + if (testDb != null) { + DatabaseTestHelper.dropTestDatabase(testDb); + currentTestDatabaseName.remove(); + } + // we remove the license key file ProcessBuilder pb = new ProcessBuilder("rm", "licenseKey"); pb.directory(new File(installDir)); @@ -87,23 +97,38 @@ 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(); - } + // IMPORTANT: Kill all processes FIRST to close database connections + // This must happen before we try to drop the database + TestingProcessManager.killAll(); + + // Now close the storage layer + StorageLayer.close(); + + // Now it's safe to drop previous test database (connections are closed) + String previousDb = currentTestDatabaseName.get(); + if (previousDb != null) { + DatabaseTestHelper.dropTestDatabase(previousDb); + currentTestDatabaseName.remove(); } + // Create a new test-specific database + String testDbName = DatabaseTestHelper.createTestDatabase(); + currentTestDatabaseName.set(testDbName); + + // Copy base config file ProcessBuilder pb = new ProcessBuilder("cp", "temp/config.yaml", "./config" + workerId + ".yaml"); pb.directory(new File(installDir)); Process process = pb.start(); process.waitFor(); + // 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); + + // Kill again and delete info (now on the new database context) TestingProcessManager.killAll(); TestingProcessManager.deleteAllInformation(); TestingProcessManager.killAll(); @@ -116,6 +141,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")); @@ -191,6 +237,23 @@ protected void failed(Throwable e, Description description) { }; } + /** + * 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 = 10; From 37efe7a3cd64e9ca376ddeabbccad8fac9568107 Mon Sep 17 00:00:00 2001 From: Mihaly Date: Sat, 7 Feb 2026 23:43:03 +0100 Subject: [PATCH 02/23] test: further test fixes --- .../postgresql/test/AccountLinkingTests.java | 6 +- .../postgresql/test/DbConnectionPoolTest.java | 56 ++++++++++++------- .../storage/postgresql/test/LogLevelTest.java | 11 ++++ .../storage/postgresql/test/LoggingTest.java | 6 +- .../storage/postgresql/test/Utils.java | 11 ++-- 5 files changed, 59 insertions(+), 31 deletions(-) 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/DbConnectionPoolTest.java b/src/test/java/io/supertokens/storage/postgresql/test/DbConnectionPoolTest.java index 17e158c8..0eae7ec8 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); @@ -262,7 +275,8 @@ public void testMinimumIdleConnections() throws Exception { Thread.sleep(65000); // let the idle connections time out 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 +293,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 +314,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 +322,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 +338,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,7 +363,8 @@ 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); @@ -366,7 +384,7 @@ 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); @@ -398,19 +416,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 - 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/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 53abfbdc..a5a650d4 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/LoggingTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/LoggingTest.java @@ -458,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/Utils.java b/src/test/java/io/supertokens/storage/postgresql/test/Utils.java index bdc985a1..7cbdd21b 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/Utils.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/Utils.java @@ -68,12 +68,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); From 019b38d99bd71d318f8aa0997eb17f8830ba6f2e Mon Sep 17 00:00:00 2001 From: Mihaly Date: Sat, 7 Feb 2026 23:47:17 +0100 Subject: [PATCH 03/23] chore: update plugin-interface to help release --- pluginInterfaceSupported.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pluginInterfaceSupported.json b/pluginInterfaceSupported.json index 0ffb8a2b..0be98720 100644 --- a/pluginInterfaceSupported.json +++ b/pluginInterfaceSupported.json @@ -1,6 +1,6 @@ { "_comment": "contains a list of plugin interfaces branch names that this core supports", "versions": [ - "8.3" + "test/external_pg" ] } From 0e67ed3b42efd8e9e2cf5af02343b484edf2dd2c Mon Sep 17 00:00:00 2001 From: Mihaly Date: Wed, 11 Feb 2026 10:51:39 +0100 Subject: [PATCH 04/23] test: stability fixes and pg stat collection support --- .gitignore | 3 +- .../supertokens/storage/postgresql/Start.java | 27 ++- .../postgresql/queries/SessionQueries.java | 3 + .../queries/UserMetadataQueries.java | 1 + .../postgresql/queries/UserRolesQueries.java | 3 + .../postgresql/test/DatabaseTestHelper.java | 205 ++++++++++++++++++ .../storage/postgresql/test/LoggingTest.java | 2 +- .../test/SuperTokensSaaSSecretTest.java | 3 +- .../storage/postgresql/test/Utils.java | 16 ++ .../test/multitenancy/StorageLayerTest.java | 24 +- .../TestForNoCrashDuringStartup.java | 35 +-- .../TestUserPoolIdChangeBehaviour.java | 8 +- 12 files changed, 293 insertions(+), 37 deletions(-) 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/Start.java b/src/main/java/io/supertokens/storage/postgresql/Start.java index 71e42c3d..540a4f60 100644 --- a/src/main/java/io/supertokens/storage/postgresql/Start.java +++ b/src/main/java/io/supertokens/storage/postgresql/Start.java @@ -1089,17 +1089,27 @@ 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) { // 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 (1-10). + // 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 >= 1 && poolNumber <= 10) { - ensureTestDatabaseExists(dbName, config); + 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)); @@ -1149,11 +1159,18 @@ private void ensureTestDatabaseExists(String dbName, JsonObject config) { try (Connection conn = DriverManager.getConnection(adminUrl, user, password); Statement stmt = conn.createStatement()) { - // Drop existing database for clean state (important for test isolation) + // 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 have active connections + // Ignore errors - database might still have connections } // Create fresh database 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..e3960f24 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);"; 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 b620d884..9cb8e0fe 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/UserMetadataQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/UserMetadataQueries.java @@ -55,6 +55,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/test/java/io/supertokens/storage/postgresql/test/DatabaseTestHelper.java b/src/test/java/io/supertokens/storage/postgresql/test/DatabaseTestHelper.java index cff205e5..98fe5cb6 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/DatabaseTestHelper.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/DatabaseTestHelper.java @@ -17,10 +17,19 @@ 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; /** @@ -221,4 +230,200 @@ public static String getUser() { 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/LoggingTest.java b/src/test/java/io/supertokens/storage/postgresql/test/LoggingTest.java index a5a650d4..8f2974e4 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/LoggingTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/LoggingTest.java @@ -326,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"); 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/Utils.java b/src/test/java/io/supertokens/storage/postgresql/test/Utils.java index 7cbdd21b..e356a8bb 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/Utils.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/Utils.java @@ -36,6 +36,7 @@ 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 { @@ -229,10 +230,25 @@ 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); + } }; } 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), From dae633105d5504705ab817ae6f379635c3376071 Mon Sep 17 00:00:00 2001 From: Mihaly Date: Wed, 11 Feb 2026 11:04:16 +0100 Subject: [PATCH 05/23] perf(test): reduce deadlock stress test iterations and thread pool Reduce iterations from 3000 to 500 and thread pool from 1000 to 200 threads across all three deadlock tests. Tighten awaitTermination from 2min to 60s with an assertion. Deadlocks still trigger reliably since even 200 threads competing for the same rows produces heavy contention. Co-Authored-By: Claude Opus 4.6 --- .../storage/postgresql/test/DeadlockTest.java | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) 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 58d5e088..70ce40bc 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(); @@ -621,7 +622,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); @@ -630,7 +631,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(), @@ -645,8 +646,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()); assertNull(process .checkOrWaitForEventInPlugin( @@ -671,13 +673,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()); @@ -691,8 +693,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()); assertNull(process .checkOrWaitForEventInPlugin( From 274a70788416c78abcba1b0de4a1aa08b3187370 Mon Sep 17 00:00:00 2001 From: Mihaly Date: Wed, 11 Feb 2026 11:17:57 +0100 Subject: [PATCH 06/23] perf(test): reduce DB connection pool test sleep from 65s to 8s Reduce idle timeout from 30s to 3s and set HikariCP housekeeping period to 1s (via system property) in both pool tests. This cuts the Thread.sleep from 65s to 8s. Also reduce concurrent sign-in operations from 10000 to 1000 in testIdleConnectionTimeout. Co-Authored-By: Claude Opus 4.6 --- .../storage/postgresql/config/PostgreSQLConfig.java | 2 ++ .../postgresql/test/DbConnectionPoolTest.java | 12 +++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) 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 07728f73..44a69ec7 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"); } 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 0eae7ec8..5fe1b42f 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/DbConnectionPoolTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/DbConnectionPoolTest.java @@ -268,11 +268,12 @@ 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", "3000"); + 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(8000); // let the idle connections time out (3s idle + 1s housekeeping + buffer) Start start = (Start) StorageLayer.getBaseStorage(process.getProcess()); String testDbName = DatabaseTestHelper.getCurrentTestDatabase(); @@ -370,7 +371,8 @@ public void testIdleConnectionTimeout() throws Exception { 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", 3000); + System.setProperty("com.zaxxer.hikari.housekeeping.periodMs", "1000"); AtomicLong errorCount = new AtomicLong(0); @@ -388,7 +390,7 @@ public void testIdleConnectionTimeout() throws Exception { 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 { @@ -420,7 +422,7 @@ public void testIdleConnectionTimeout() throws Exception { assertEquals(0, errorCount.get()); - Thread.sleep(65000); // let the idle connections time out + Thread.sleep(8000); // let the idle connections time out (3s idle + 1s housekeeping + buffer) assertEquals(5, start.getDbActivityCount(getTenantDatabaseName(1))); From f9194537143de2b76142eb10662ba6529f3d355c Mon Sep 17 00:00:00 2001 From: Mihaly Date: Wed, 11 Feb 2026 21:56:19 +0100 Subject: [PATCH 07/23] perf(test): replace DROP/CREATE DB with TRUNCATE-based cleanup - Cache per-worker test database (create once, reuse across tests) - Add truncateAllData() that truncates all tables instead of dropping and recreating the database each test - Use killAll(false) in reset() to preserve tables across tests, making CREATE TABLE IF NOT EXISTS statements no-ops - Add killAll(boolean) overload to TestingProcessManager - Update DbConnectionPoolTest idle timeout to use HikariCP minimum Co-Authored-By: Claude Opus 4.6 --- .../postgresql/test/DatabaseTestHelper.java | 72 +++++++++++++++---- .../postgresql/test/DbConnectionPoolTest.java | 8 +-- .../test/TestingProcessManager.java | 6 +- .../storage/postgresql/test/Utils.java | 36 ++++------ 4 files changed, 81 insertions(+), 41 deletions(-) diff --git a/src/test/java/io/supertokens/storage/postgresql/test/DatabaseTestHelper.java b/src/test/java/io/supertokens/storage/postgresql/test/DatabaseTestHelper.java index 98fe5cb6..91013993 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/DatabaseTestHelper.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/DatabaseTestHelper.java @@ -34,7 +34,8 @@ /** * Helper class for managing test-specific PostgreSQL databases. - * Each test gets its own isolated database to prevent interference. + * 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 { @@ -43,6 +44,11 @@ public class DatabaseTestHelper { // 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"); @@ -74,10 +80,19 @@ public static String generateTestDatabaseName() { } /** - * Create a new test database. - * Connects to the admin database to create the test database. + * 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 { @@ -93,8 +108,12 @@ public static String createTestDatabase() { // Create the database stmt.executeUpdate("CREATE DATABASE " + dbName); - // System.out.println("[DatabaseTestHelper] Created test 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; @@ -105,16 +124,12 @@ public static String createTestDatabase() { } /** - * Drop the current test database. - * Should be called after the test completes. + * Clear the current test database reference. + * The database persists and is reused for subsequent tests in this worker. */ public static void dropCurrentTestDatabase() { - String dbName = currentTestDatabase.get(); - if (dbName == null) { - return; - } - - dropTestDatabase(dbName); + // Don't drop - just clear the thread-local reference + // The database is reused across tests in this worker currentTestDatabase.remove(); } @@ -152,6 +167,39 @@ public static void dropTestDatabase(String dbName) { } } + /** + * 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. */ 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 5fe1b42f..49964ecb 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/DbConnectionPoolTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/DbConnectionPoolTest.java @@ -268,12 +268,12 @@ 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", "3000"); + 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(8000); // let the idle connections time out (3s idle + 1s housekeeping + buffer) + Thread.sleep(15000); // let the idle connections time out (10s idle + 1s housekeeping + buffer) Start start = (Start) StorageLayer.getBaseStorage(process.getProcess()); String testDbName = DatabaseTestHelper.getCurrentTestDatabase(); @@ -371,7 +371,7 @@ public void testIdleConnectionTimeout() throws Exception { start.modifyConfigToAddANewUserPoolForTesting(config, 1); config.addProperty("postgresql_connection_pool_size", 300); config.addProperty("postgresql_minimum_idle_connections", 5); - config.addProperty("postgresql_idle_connection_timeout", 3000); + config.addProperty("postgresql_idle_connection_timeout", 10000); // HikariCP minimum is 10000ms System.setProperty("com.zaxxer.hikari.housekeeping.periodMs", "1000"); AtomicLong errorCount = new AtomicLong(0); @@ -422,7 +422,7 @@ public void testIdleConnectionTimeout() throws Exception { assertEquals(0, errorCount.get()); - Thread.sleep(8000); // let the idle connections time out (3s idle + 1s housekeeping + buffer) + Thread.sleep(15000); // let the idle connections time out (10s idle + 1s housekeeping + buffer) assertEquals(5, start.getDbActivityCount(getTenantDatabaseName(1))); 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/Utils.java b/src/test/java/io/supertokens/storage/postgresql/test/Utils.java index e356a8bb..3b7466f9 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/Utils.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/Utils.java @@ -49,12 +49,8 @@ public abstract class Utils extends Mockito { public static void afterTesting() { String installDir = "../"; try { - // Drop the test-specific database - String testDb = currentTestDatabaseName.get(); - if (testDb != null) { - DatabaseTestHelper.dropTestDatabase(testDb); - currentTestDatabaseName.remove(); - } + // Clean up the test database reference + currentTestDatabaseName.remove(); // we remove the license key file ProcessBuilder pb = new ProcessBuilder("rm", "licenseKey"); @@ -97,25 +93,22 @@ public static void reset() { String installDir = "../"; String workerId = System.getProperty("org.gradle.test.worker"); try { - // IMPORTANT: Kill all processes FIRST to close database connections - // This must happen before we try to drop the database - TestingProcessManager.killAll(); + // 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); - // Now close the storage layer + // Close the storage layer (releases HikariCP pools etc.) StorageLayer.close(); - // Now it's safe to drop previous test database (connections are closed) - String previousDb = currentTestDatabaseName.get(); - if (previousDb != null) { - DatabaseTestHelper.dropTestDatabase(previousDb); - currentTestDatabaseName.remove(); - } - - // Create a new test-specific database + // Get or create the per-worker test database (created once, reused across tests) String testDbName = DatabaseTestHelper.createTestDatabase(); currentTestDatabaseName.set(testDbName); - // Copy base config file + // 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(); @@ -128,11 +121,6 @@ public static void reset() { setValueInConfigDirectly("postgresql_user", "\"" + DatabaseTestHelper.getUser() + "\"", workerId); setValueInConfigDirectly("postgresql_password", "\"" + DatabaseTestHelper.getPassword() + "\"", workerId); - // Kill again and delete info (now on the new database context) - TestingProcessManager.killAll(); - TestingProcessManager.deleteAllInformation(); - TestingProcessManager.killAll(); - byteArrayOutputStream = new ByteArrayOutputStream(); System.setErr(new PrintStream(byteArrayOutputStream)); } catch (Exception e) { From 9d48e41bc59dac3cc4221965c193d6ba94d1dd97 Mon Sep 17 00:00:00 2001 From: Mihaly Date: Tue, 17 Feb 2026 11:54:48 +0100 Subject: [PATCH 08/23] chore: re-generate implementation dependencies --- implementationDependencies.json | 65 +++++---------------------------- 1 file changed, 10 insertions(+), 55 deletions(-) diff --git a/implementationDependencies.json b/implementationDependencies.json index f6631891..30666652 100644 --- a/implementationDependencies.json +++ b/implementationDependencies.json @@ -1,70 +1,25 @@ { "_comment": "Contains list of implementation dependencies URL for this project. This is a generated file, don't modify the contents by hand.", "list": [ - { - "jar":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.18.2/jackson-dataformat-yaml-2.18.2.jar", - "name":"jackson-dataformat-yaml 2.18.2", - "src":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.18.2/jackson-dataformat-yaml-2.18.2-sources.jar" - }, - { - "jar":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/core/jackson-databind/2.18.2/jackson-databind-2.18.2.jar", - "name":"jackson-databind 2.18.2", - "src":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/core/jackson-databind/2.18.2/jackson-databind-2.18.2-sources.jar" - }, - { - "jar":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/core/jackson-core/2.18.2/jackson-core-2.18.2.jar", - "name":"jackson-core 2.18.2", - "src":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/core/jackson-core/2.18.2/jackson-core-2.18.2-sources.jar" - }, - { - "jar":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/core/jackson-annotations/2.18.2/jackson-annotations-2.18.2.jar", - "name":"jackson-annotations 2.18.2", - "src":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/core/jackson-annotations/2.18.2/jackson-annotations-2.18.2-sources.jar" - }, - { - "jar":"https://repo.maven.apache.org/maven2/org/yaml/snakeyaml/2.3/snakeyaml-2.3.jar", - "name":"snakeyaml 2.3", - "src":"https://repo.maven.apache.org/maven2/org/yaml/snakeyaml/2.3/snakeyaml-2.3-sources.jar" - }, - { - "jar":"https://repo.maven.apache.org/maven2/ch/qos/logback/logback-classic/1.5.13/logback-classic-1.5.13.jar", - "name":"logback-classic 1.5.13", - "src":"https://repo.maven.apache.org/maven2/ch/qos/logback/logback-classic/1.5.13/logback-classic-1.5.13-sources.jar" - }, - { - "jar":"https://repo.maven.apache.org/maven2/ch/qos/logback/logback-core/1.5.13/logback-core-1.5.13.jar", - "name":"logback-core 1.5.13", - "src":"https://repo.maven.apache.org/maven2/ch/qos/logback/logback-core/1.5.13/logback-core-1.5.13-sources.jar" - }, - { - "jar":"https://repo.maven.apache.org/maven2/org/slf4j/slf4j-api/2.0.15/slf4j-api-2.0.15.jar", - "name":"slf4j-api 2.0.15", - "src":"https://repo.maven.apache.org/maven2/org/slf4j/slf4j-api/2.0.15/slf4j-api-2.0.15-sources.jar" - }, - { - "jar":"https://repo.maven.apache.org/maven2/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar", - "name":"jsr305 3.0.2", - "src":"https://repo.maven.apache.org/maven2/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2-sources.jar" - }, - { - "jar":"https://repo.maven.apache.org/maven2/org/jetbrains/annotations/13.0/annotations-13.0.jar", - "name":"annotations 13.0", - "src":"https://repo.maven.apache.org/maven2/org/jetbrains/annotations/13.0/annotations-13.0-sources.jar" - }, - { - "jar":"https://repo.maven.apache.org/maven2/com/google/code/gson/gson/2.3.1/gson-2.3.1.jar", - "name":"gson 2.3.1", - "src":"https://repo.maven.apache.org/maven2/com/google/code/gson/gson/2.3.1/gson-2.3.1-sources.jar" - }, { "jar":"https://repo.maven.apache.org/maven2/com/zaxxer/HikariCP/6.3.0/HikariCP-6.3.0.jar", "name":"HikariCP 6.3.0", "src":"https://repo.maven.apache.org/maven2/com/zaxxer/HikariCP/6.3.0/HikariCP-6.3.0-sources.jar" }, + { + "jar":"https://repo.maven.apache.org/maven2/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar", + "name":"slf4j-api 1.7.36", + "src":"https://repo.maven.apache.org/maven2/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36-sources.jar" + }, { "jar":"https://repo.maven.apache.org/maven2/org/postgresql/postgresql/42.7.2/postgresql-42.7.2.jar", "name":"postgresql 42.7.2", "src":"https://repo.maven.apache.org/maven2/org/postgresql/postgresql/42.7.2/postgresql-42.7.2-sources.jar" + }, + { + "jar":"https://repo.maven.apache.org/maven2/org/checkerframework/checker-qual/3.42.0/checker-qual-3.42.0.jar", + "name":"checker-qual 3.42.0", + "src":"https://repo.maven.apache.org/maven2/org/checkerframework/checker-qual/3.42.0/checker-qual-3.42.0-sources.jar" } ] } \ No newline at end of file From b5b26dd08382ebde3721381d4840c367aa18ef64 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Sat, 28 Mar 2026 16:13:21 +0100 Subject: [PATCH 09/23] feat: add time_joined columns to app_id_to_user_id table Add time_joined and primary_or_recipe_user_time_joined columns to app_id_to_user_id with 4 pagination indexes. Update all signup write paths (EP, Pless, TP, WebAuthn + bulk import) and account linking methods (updateTimeJoined, unlinkAccounts) to maintain these columns. This prepares app_id_to_user_id to replace all_auth_recipe_users for pagination queries (DEPRECATE ticket 01). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../queries/EmailPasswordQueries.java | 10 ++- .../postgresql/queries/GeneralQueries.java | 82 ++++++++++++++++--- .../queries/PasswordlessQueries.java | 10 ++- .../postgresql/queries/ThirdPartyQueries.java | 10 ++- .../postgresql/queries/WebAuthNQueries.java | 5 +- 5 files changed, 98 insertions(+), 19 deletions(-) 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..49005490 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/EmailPasswordQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/EmailPasswordQueries.java @@ -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 -> { 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..3a155775 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java @@ -261,6 +261,8 @@ 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") @@ -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())) { @@ -1512,9 +1542,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 +1565,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 +1588,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 -> { @@ -2143,16 +2187,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..9f342d41 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/PasswordlessQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/PasswordlessQueries.java @@ -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); }); } @@ -1082,7 +1085,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 +1134,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/ThirdPartyQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/ThirdPartyQueries.java index 121c7f17..59709bdd 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/ThirdPartyQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/ThirdPartyQueries.java @@ -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); }); } @@ -576,7 +579,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 +623,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/WebAuthNQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/WebAuthNQueries.java index b4b766cf..4d6e2888 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/WebAuthNQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/WebAuthNQueries.java @@ -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 From 7ce48b21b149289c2f1bf00c3fb60acf68aaa5f0 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Sat, 28 Mar 2026 17:40:48 +0100 Subject: [PATCH 10/23] feat: add ON UPDATE CASCADE to all FKs referencing app_id_to_user_id Prepares for future CASCADE FK migration by adding ON UPDATE CASCADE to all foreign keys that reference app_id_to_user_id(app_id, user_id). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../storage/postgresql/queries/EmailPasswordQueries.java | 2 +- .../storage/postgresql/queries/GeneralQueries.java | 6 +++--- .../storage/postgresql/queries/PasswordlessQueries.java | 2 +- .../storage/postgresql/queries/ThirdPartyQueries.java | 2 +- .../storage/postgresql/queries/UserIdMappingQueries.java | 2 +- .../storage/postgresql/queries/WebAuthNQueries.java | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) 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 49005490..2a55e4c9 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)" + ");"; 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 3a155775..9187154d 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 } @@ -268,7 +268,7 @@ private static String getQueryToCreateAppIdToUserIdTable(Start start) { + "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" 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 9f342d41..734d1500 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)" + ");"; 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 59709bdd..ac92d564 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)" + ");"; 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/WebAuthNQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/WebAuthNQueries.java index 4d6e2888..a829630b 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 " + ");"; } From b293684b66ae694997f8df41c193f96355eab991 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Sat, 28 Mar 2026 22:20:15 +0100 Subject: [PATCH 11/23] feat: migrate getPrimaryUserIdUsingEmail to use recipe_user_tenants table Replaces the old query that joined emailpassword_user_to_tenant with all_auth_recipe_users. The new query joins recipe_user_tenants with app_id_to_user_id, which works for both linked and unlinked users without needing to query primary_user_tenants separately. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../postgresql/queries/EmailPasswordQueries.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 2a55e4c9..10b7479b 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/EmailPasswordQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/EmailPasswordQueries.java @@ -582,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()); From ed6b19c5ead9563f0f27dd76e9a47b1682abe2e9 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Sat, 28 Mar 2026 22:53:31 +0100 Subject: [PATCH 12/23] feat: migrate passwordless read queries to use reservation tables Migrate 5 methods in PasswordlessQueries.java from passwordless_user_to_tenant and all_auth_recipe_users to recipe_user_tenants and app_id_to_user_id: - deleteDevicesByPhoneNumber_Transaction - deleteDevicesByEmail_Transaction - getUserInfosWithTenant_Transaction - getPrimaryUserIdUsingEmail - getPrimaryUserByPhoneNumber Co-Authored-By: Claude Opus 4.6 (1M context) --- .../queries/PasswordlessQueries.java | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) 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 734d1500..a53326e8 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/PasswordlessQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/PasswordlessQueries.java @@ -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 -> { @@ -514,12 +514,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); @@ -848,11 +848,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()); @@ -869,11 +870,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()); From 2ea4bd95afc12c2f9e743074c65255bbf78e362c Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Sat, 28 Mar 2026 23:20:43 +0100 Subject: [PATCH 13/23] fix: kill remaining SuperTokens processes in afterTesting to prevent JVM hang afterTesting() only cleaned up files but never killed test processes. The last test in each class left a SuperTokens instance running with non-daemon threads (webserver, cron jobs, HikariCP) that prevented the test JVM from exiting, causing gradle to hang indefinitely. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/io/supertokens/storage/postgresql/test/Utils.java | 6 ++++++ 1 file changed, 6 insertions(+) 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 0f153c45..f48458a0 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/Utils.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/Utils.java @@ -49,6 +49,12 @@ public abstract class Utils extends Mockito { 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(); From ca7f6e1b6f35b9cf95adce2b1901d05d10c581c0 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Sun, 29 Mar 2026 00:04:02 +0100 Subject: [PATCH 14/23] feat: migrate ThirdParty reads to recipe_user_tenants (DEPRECATE-06) Migrate getUserIdByThirdPartyInfo and getPrimaryUserIdUsingEmail to use recipe_user_tenants + app_id_to_user_id instead of thirdparty_user_to_tenant + all_auth_recipe_users. Delete dead code: getPrimaryUserIdUsingEmail_Transaction and getPrimaryUserIdsUsingMultipleEmails_Transaction (no callers). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../postgresql/queries/ThirdPartyQueries.java | 74 ++++--------------- 1 file changed, 14 insertions(+), 60 deletions(-) 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 ac92d564..00b29ec4 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/ThirdPartyQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/ThirdPartyQueries.java @@ -378,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()); @@ -434,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()); @@ -455,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 { From 7f20e8b5cd44c424b00b38e1a805ce97b90ea0fe Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Sun, 29 Mar 2026 00:52:38 +0100 Subject: [PATCH 15/23] feat: migrate WebAuthn reads to reservation tables (DEPRECATE-07) Migrate 4 methods in WebAuthNQueries.java off webauthn_user_to_tenant and all_auth_recipe_users to use recipe_user_tenants (tenant-scoped) and recipe_user_account_infos (app-scoped) joined with app_id_to_user_id. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../postgresql/queries/WebAuthNQueries.java | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) 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 a829630b..6490bd2d 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/WebAuthNQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/WebAuthNQueries.java @@ -427,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()) { @@ -450,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()); @@ -473,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()); From 68953c2b7db095c8996bb02e8775f403c32192c6 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Sun, 29 Mar 2026 01:30:03 +0100 Subject: [PATCH 16/23] feat: migrate getUsers() dashboard search to unified reservation table queries Replace per-recipe UNION queries (emailpassword, thirdparty, passwordless, webauthn user_to_tenant tables + all_auth_recipe_users) with unified queries using app_id_to_user_id + recipe_user_tenants. Uses ILIKE for case-insensitive search. Non-search pagination path also migrated to use app_id_to_user_id + recipe_user_tenants. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../postgresql/queries/GeneralQueries.java | 321 +++++++----------- 1 file changed, 121 insertions(+), 200 deletions(-) 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 9187154d..d76352a7 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java @@ -1115,207 +1115,121 @@ 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 += " )"; - } - - // 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"); - - } + query.append(")"); + + } 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 (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(","); } } @@ -1328,18 +1242,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()); } } @@ -1359,17 +1276,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()); } } From e6363304b0062571ac1ba585bb3fea6fdc5d55a8 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Sun, 29 Mar 2026 10:45:53 +0200 Subject: [PATCH 17/23] feat: migrate listUsersByAccountInfo to reservation tables Migrate listPrimaryUsersByEmail, listPrimaryUsersByPhoneNumber, getPrimaryUserByThirdPartyInfo, and listPrimaryUsersByThirdPartyInfo (including _Transaction variant) to use recipe_user_tenants and recipe_user_account_infos tables instead of per-recipe query methods. New unified lookup methods added to AccountInfoQueries.java replace 4 separate per-recipe queries with single queries against the reservation tables. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../queries/AccountInfoQueries.java | 145 ++++++++++++++++++ .../postgresql/queries/GeneralQueries.java | 40 +---- 2 files changed, 151 insertions(+), 34 deletions(-) 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..5dea936d 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/AccountInfoQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/AccountInfoQueries.java @@ -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/GeneralQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java index d76352a7..2fdffe80 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java @@ -1526,7 +1526,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); @@ -1543,8 +1543,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); @@ -1557,29 +1557,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); @@ -1594,13 +1572,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); @@ -1616,7 +1588,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); } From c09785eda600ef71ba2dfa1d7d9b301d275fe033 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Sun, 29 Mar 2026 11:24:46 +0200 Subject: [PATCH 18/23] feat: migrate active users, count, and account linking queries off all_auth_recipe_users Migrate getUsersCount (app-scoped and tenant-scoped), checkIfUsesAccountLinking to use app_id_to_user_id and recipe_user_tenants instead of all_auth_recipe_users. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../storage/postgresql/queries/GeneralQueries.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 2fdffe80..f76201df 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java @@ -972,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 ("); @@ -1007,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) { @@ -1021,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()); @@ -2044,7 +2045,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 -> { From 074263545ee6520c1b71e9fe8b01db2887cf4e7b Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Sun, 29 Mar 2026 16:23:09 +0200 Subject: [PATCH 19/23] feat: migrate getPrimaryUserInfo, getTenantIds, and session queries off all_auth_recipe_users - getPrimaryUserInfoForUserIds: LEFT JOIN recipe_user_tenants for tenant_id, time_joined from app_id_to_user_id - getPrimaryUserIdStrForUserId: read from app_id_to_user_id - getTenantIdsForUserIds: use recipe_user_tenants with DISTINCT - SessionQueries: use app_id_to_user_id for primary_or_recipe_user_id lookups Co-Authored-By: Claude Opus 4.6 (1M context) --- .../postgresql/queries/GeneralQueries.java | 26 +++++++++---------- .../postgresql/queries/SessionQueries.java | 6 ++--- 2 files changed, 16 insertions(+), 16 deletions(-) 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 f76201df..e8ff69ee 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java @@ -1622,7 +1622,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); @@ -1672,10 +1672,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()) + @@ -1769,10 +1769,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() + @@ -1873,9 +1873,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("?"); @@ -1916,9 +1916,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("?"); 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 e3960f24..b08ee2ae 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/SessionQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/SessionQueries.java @@ -158,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 " + @@ -172,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 " + @@ -430,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 = ?"; From c3da1d422172c6cfe80da29f9f2f52e1f52eb256 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Sun, 29 Mar 2026 19:01:32 +0200 Subject: [PATCH 20/23] feat: migrate tenant-scoped doesUserIdExist to reservation tables Replace all_auth_recipe_users with recipe_user_tenants and primary_user_tenants for the tenant-scoped doesUserIdExist query. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../storage/postgresql/queries/GeneralQueries.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 e8ff69ee..84a1cfed 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java @@ -1070,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()); From 65849b35b9c3fbbe27def32e698d9c088291f006 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Sun, 29 Mar 2026 22:06:09 +0200 Subject: [PATCH 21/23] feat: fix Primery typo and optimize lockUsers to single query Part A (ISO-021): Fix wasAlreadyAPrimeryUserResult typo in AccountInfoQueries. Part B (ISO-025): Rewrite lockUsers() to use a single query with FOR UPDATE instead of 2N+M individual queries. Uses UNION to fetch both requested users and their primary users, with ORDER BY user_id for deadlock prevention. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../queries/AccountInfoQueries.java | 2 +- .../queries/UserLockingQueries.java | 151 ++++++------------ 2 files changed, 51 insertions(+), 102 deletions(-) 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 5dea936d..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]); } 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..a5fa73bb 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"); + 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; - }); - } } From 7a34bdf2f78e6967dc98f193231857fd0c3d8d40 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Mon, 30 Mar 2026 19:16:24 +0200 Subject: [PATCH 22/23] fix: Phase 1 migration bugs in PostgreSQL queries - PasswordlessQueries: fix phone_number column name in ResultSet read, remove dead PasswordlessDeviceRowMapper call, insert both email AND phone rows into recipe_user_tenants for dual-contact users - GeneralQueries: move updateTimeJoinedForPrimaryUser after app_id_to_user_id update in linkAccounts so MIN(time_joined) sees all linked users; add hasPhones&&hasProviders and hasEmails&&hasPhones&&hasProviders search tag cases to prevent false matches in getUsers dashboard search - UserLockingQueries: trim user_id from ResultSet to handle CHAR(36) padding in HashMap lookup (fixes lockUser with non-UUID user IDs) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../postgresql/queries/GeneralQueries.java | 32 +++++++++++++++++-- .../queries/PasswordlessQueries.java | 23 ++++++------- .../queries/UserLockingQueries.java | 2 +- 3 files changed, 40 insertions(+), 17 deletions(-) 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 84a1cfed..c67bc983 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java @@ -1161,6 +1161,16 @@ public static AuthRecipeUserInfo[] getUsers(Start start, TenantIdentifier tenant queryParams.add(dashboardSearchTags.phoneNumbers.get(i) + "%"); } 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(")"); + } } else if (hasEmails && hasProviders) { // Email + Provider: single row match (email rows of thirdparty users have third_party_id) @@ -1179,6 +1189,22 @@ public static AuthRecipeUserInfo[] getUsers(Start start, TenantIdentifier tenant } 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++) { @@ -1398,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 = (" + @@ -1414,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, 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 a53326e8..289f8e92 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/PasswordlessQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/PasswordlessQueries.java @@ -457,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 @@ -531,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]); }); 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 a5fa73bb..7c1e25c2 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/UserLockingQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/UserLockingQueries.java @@ -91,7 +91,7 @@ public static List lockUsers(Start start, Connection con, AppIdentif }, rs -> { Map map = new HashMap<>(); while (rs.next()) { - String uid = rs.getString("user_id"); + 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; From 14f6f29597b6c08aa22ad9acae14f087c7c3abe2 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Mon, 30 Mar 2026 21:19:16 +0200 Subject: [PATCH 23/23] test: add comprehensive reservation table integrity checker and tests Extend RaceTestUtils with full integrity checks (I1-I6) for all account_info_types (EMAIL, PHONE_NUMBER, THIRD_PARTY): - I1/I2: primary_user_tenants completeness and accuracy - I3: recipe_user_tenants consistency - I4: recipe_user_tenants row count per tenant - I5: recipe_user_account_infos row count - I6: time_joined consistency in app_id_to_user_id Create ReservationTableIntegrityTest with 10 tests covering: - Passwordless dual email+phone reservations (Bug #3 regression) - Time_joined consistency after linking (Bug #2 regression) - Unlink cleanup and primary reservation preservation - Dashboard search tag combinations (Bug #4 regression) - Cross-recipe linking, third-party type, email clearing Hook integrity checks into ExceptionParsingTest signup methods. Update all 6 race test classes to use generalized checker. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../postgresql/test/ExceptionParsingTest.java | 17 + .../postgresql/test/MultitenancyRaceTest.java | 10 +- .../postgresql/test/PasswordlessRaceTest.java | 6 +- .../postgresql/test/RaceConditionTest.java | 10 +- .../postgresql/test/RaceTestUtils.java | 476 ++++++++++++++---- .../test/ReservationTableIntegrityTest.java | 373 ++++++++++++++ .../postgresql/test/ThirdPartyRaceTest.java | 10 +- .../postgresql/test/UnlinkRaceTest.java | 4 +- .../postgresql/test/WebAuthnRaceTest.java | 6 +- 9 files changed, 790 insertions(+), 122 deletions(-) create mode 100644 src/test/java/io/supertokens/storage/postgresql/test/ReservationTableIntegrityTest.java 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/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/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/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) {