|
| 1 | +/* |
| 2 | + * Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. |
| 3 | + * |
| 4 | + * This software is licensed under the Apache License, Version 2.0 (the |
| 5 | + * "License") as published by the Apache Software Foundation. |
| 6 | + * |
| 7 | + * You may not use this file except in compliance with the License. You may |
| 8 | + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | + * |
| 10 | + * Unless required by applicable law or agreed to in writing, software |
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| 12 | + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| 13 | + * License for the specific language governing permissions and limitations |
| 14 | + * under the License. |
| 15 | + */ |
| 16 | + |
| 17 | +package io.supertokens.storage.postgresql.test; |
| 18 | + |
| 19 | +import io.supertokens.ProcessState; |
| 20 | +import io.supertokens.authRecipe.AuthRecipe; |
| 21 | +import io.supertokens.emailpassword.EmailPassword; |
| 22 | +import io.supertokens.featureflag.EE_FEATURES; |
| 23 | +import io.supertokens.featureflag.FeatureFlagTestContent; |
| 24 | +import io.supertokens.pluginInterface.MigrationMode; |
| 25 | +import io.supertokens.pluginInterface.STORAGE_TYPE; |
| 26 | +import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; |
| 27 | +import io.supertokens.pluginInterface.authRecipe.exceptions.AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException; |
| 28 | +import io.supertokens.storage.postgresql.Start; |
| 29 | +import io.supertokens.storage.postgresql.config.Config; |
| 30 | +import io.supertokens.storageLayer.StorageLayer; |
| 31 | +import io.supertokens.thirdparty.ThirdParty; |
| 32 | +import org.junit.AfterClass; |
| 33 | +import org.junit.Before; |
| 34 | +import org.junit.Rule; |
| 35 | +import org.junit.Test; |
| 36 | +import org.junit.rules.TestRule; |
| 37 | + |
| 38 | +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.execute; |
| 39 | +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.update; |
| 40 | +import static org.junit.Assert.*; |
| 41 | + |
| 42 | +/** |
| 43 | + * Regression test for the fan-out bug in addPrimaryUserAccountInfo_Transaction. |
| 44 | + * |
| 45 | + * Root cause: the INSERT…SELECT JOIN on recipe_user_tenants × recipe_user_account_infos |
| 46 | + * did not include third_party_id / third_party_user_id in the JOIN condition. When |
| 47 | + * recipe_user_account_infos contains two EMAIL rows for the same user with the same |
| 48 | + * account_info_value but different (third_party_id, third_party_user_id), a single |
| 49 | + * recipe_user_tenants row fans out to both, producing duplicate |
| 50 | + * (tenant_id, account_info_type, account_info_value) tuples in the SELECT output. |
| 51 | + * |
| 52 | + * PostgreSQL's INSERT … ON CONFLICT DO UPDATE then throws: |
| 53 | + * "ON CONFLICT DO UPDATE command cannot affect row a second time" (SQLSTATE 55000) |
| 54 | + * because both duplicates attempt to update the same primary_user_tenants row. |
| 55 | + * |
| 56 | + * The fix is SELECT DISTINCT in the INSERT…SELECT, which collapses the duplicates |
| 57 | + * before they reach the ON CONFLICT clause. |
| 58 | + * |
| 59 | + * Test strategy: |
| 60 | + * 0. Force MIGRATED mode so that makePrimaryUser_Transaction routes through |
| 61 | + * addPrimaryUserAccountInfo_Transaction. Without this the process runs in |
| 62 | + * LEGACY mode and the buggy method is never called. |
| 63 | + * 1. Make an EmailPassword user primary → seeds primary_user_tenants with the |
| 64 | + * conflict target (public, email, test@example.com, EP_USER). |
| 65 | + * 2. ThirdParty sign-up with the same email → adds the normal EMAIL row: |
| 66 | + * (thirdparty, email, "google", "user-google", "test@example.com"). |
| 67 | + * 3. Directly inject a spurious second EMAIL row with empty third_party_id: |
| 68 | + * (thirdparty, email, "", "", "test@example.com"). |
| 69 | + * 4. Call createPrimaryUser on the ThirdParty user. |
| 70 | + * |
| 71 | + * Without SELECT DISTINCT: PSQLException (wrapped as StorageQueryException). |
| 72 | + * With SELECT DISTINCT: AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException. |
| 73 | + */ |
| 74 | +public class MakePrimaryUserFanOutTest { |
| 75 | + |
| 76 | + @Rule |
| 77 | + public TestRule watchman = Utils.getOnFailure(); |
| 78 | + |
| 79 | + @AfterClass |
| 80 | + public static void afterTesting() { |
| 81 | + Utils.afterTesting(); |
| 82 | + } |
| 83 | + |
| 84 | + @Before |
| 85 | + public void beforeEach() { |
| 86 | + Utils.reset(); |
| 87 | + } |
| 88 | + |
| 89 | + @Test |
| 90 | + public void createPrimaryUser_fanOutBug_spuriousEmailRowCausesConflictError() throws Exception { |
| 91 | + String[] args = {"../"}; |
| 92 | + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args, false); |
| 93 | + FeatureFlagTestContent.getInstance(process.getProcess()) |
| 94 | + .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{ |
| 95 | + EE_FEATURES.ACCOUNT_LINKING, EE_FEATURES.MULTI_TENANCY}); |
| 96 | + process.startProcess(); |
| 97 | + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); |
| 98 | + |
| 99 | + if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) { |
| 100 | + return; |
| 101 | + } |
| 102 | + if (StorageLayer.isInMemDb(process.getProcess())) { |
| 103 | + return; |
| 104 | + } |
| 105 | + |
| 106 | + // Force MIGRATED mode so that makePrimaryUser_Transaction routes through |
| 107 | + // addPrimaryUserAccountInfo_Transaction (the method containing the fan-out |
| 108 | + // bug) rather than the legacy path. Without this the test always passes |
| 109 | + // because LEGACY mode never calls the buggy/fixed method. |
| 110 | + Start start = (Start) StorageLayer.getStorage(process.getProcess()); |
| 111 | + Config.getConfig(start).setMigrationModeForTesting(MigrationMode.MIGRATED); |
| 112 | + |
| 113 | + // Step 1 — create EP primary user, seeding primary_user_tenants with |
| 114 | + // (public, email, test@example.com, EP_USER). |
| 115 | + AuthRecipeUserInfo epUser = EmailPassword.signUp( |
| 116 | + process.getProcess(), "test@example.com", "pass1234"); |
| 117 | + AuthRecipe.createPrimaryUser(process.getProcess(), epUser.getSupertokensUserId()); |
| 118 | + |
| 119 | + // Step 2 — ThirdParty sign-up with the same email. |
| 120 | + // Produces two rows in recipe_user_account_infos for TP_USER: |
| 121 | + // a) recipe_id=thirdparty, account_info_type=email, tp_id=google, tp_uid=user-google |
| 122 | + // b) recipe_id=thirdparty, account_info_type=tparty, tp_id='', tp_uid='' |
| 123 | + ThirdParty.SignInUpResponse tpResponse = ThirdParty.signInUp( |
| 124 | + process.getProcess(), "google", "user-google", "test@example.com"); |
| 125 | + String tpUserId = tpResponse.user.getSupertokensUserId(); |
| 126 | + |
| 127 | + // Step 3 — inject a spurious second EMAIL row with empty third_party_id. |
| 128 | + // |
| 129 | + // PK of recipe_user_account_infos: |
| 130 | + // (app_id, recipe_id, recipe_user_id, account_info_type, third_party_id, third_party_user_id) |
| 131 | + // |
| 132 | + // The existing row has (thirdparty, email, google, user-google) so this new row |
| 133 | + // (thirdparty, email, '', '') has a different PK and does not conflict on insert. |
| 134 | + // |
| 135 | + // Now recipe_user_account_infos has TWO rows matching |
| 136 | + // (recipe_user_id=TP_USER, recipe_id=thirdparty, account_info_type=email, |
| 137 | + // account_info_value=test@example.com) — differing only in third_party_id. |
| 138 | + String accountInfoTable = Config.getConfig(start).getRecipeUserAccountInfosTable(); |
| 139 | + // Confirm migration mode is MIGRATED before injection — if this fails the |
| 140 | + // test would pass for the wrong reason (legacy path, no fan-out possible). |
| 141 | + assertEquals("MIGRATED mode must be active for this test to exercise the fan-out bug", |
| 142 | + MigrationMode.MIGRATED, Config.getConfig(start).getMigrationMode()); |
| 143 | + |
| 144 | + update(start, |
| 145 | + "INSERT INTO " + accountInfoTable |
| 146 | + + " (app_id, recipe_user_id, recipe_id, account_info_type," |
| 147 | + + " third_party_id, third_party_user_id, account_info_value)" |
| 148 | + + " VALUES (?, ?, 'thirdparty', 'email', '', '', ?)", |
| 149 | + pst -> { |
| 150 | + pst.setString(1, "public"); |
| 151 | + pst.setString(2, tpUserId); |
| 152 | + pst.setString(3, "test@example.com"); |
| 153 | + }); |
| 154 | + |
| 155 | + // Confirm both email rows are present — if this fails the injection silently |
| 156 | + // did not create the second row and the test cannot demonstrate the fan-out. |
| 157 | + int emailRowCount = execute(start, |
| 158 | + "SELECT COUNT(*) FROM " + accountInfoTable |
| 159 | + + " WHERE app_id = 'public' AND recipe_user_id = ? AND account_info_type = 'email'", |
| 160 | + pst -> pst.setString(1, tpUserId), |
| 161 | + rs -> { |
| 162 | + rs.next(); |
| 163 | + return rs.getInt(1); |
| 164 | + }); |
| 165 | + assertEquals("Spurious injection must produce exactly 2 email rows to trigger fan-out", 2, emailRowCount); |
| 166 | + |
| 167 | + // Step 4 — attempt to make the ThirdParty user primary. |
| 168 | + // |
| 169 | + // Inside addPrimaryUserAccountInfo_Transaction the INSERT…SELECT joins |
| 170 | + // recipe_user_tenants r with recipe_user_account_infos ai on |
| 171 | + // (recipe_user_id, recipe_id, account_info_type, account_info_value). |
| 172 | + // The single recipe_user_tenants row for the ThirdParty user's email matches |
| 173 | + // BOTH ai rows (real + spurious), so the SELECT emits two identical tuples: |
| 174 | + // (public, email, test@example.com, TP_USER) |
| 175 | + // (public, email, test@example.com, TP_USER) ← duplicate |
| 176 | + // |
| 177 | + // Both duplicates try to DO UPDATE the same primary_user_tenants row |
| 178 | + // (public, email, test@example.com, EP_USER), triggering SQLSTATE 55000. |
| 179 | + // |
| 180 | + // With SELECT DISTINCT the duplicate is collapsed before the INSERT, so only |
| 181 | + // one conflict row reaches primary_user_tenants. The conflict is detected, |
| 182 | + // and AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException is thrown. |
| 183 | + try { |
| 184 | + AuthRecipe.createPrimaryUser(process.getProcess(), tpUserId); |
| 185 | + fail("Expected AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException"); |
| 186 | + } catch (AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException e) { |
| 187 | + assertEquals(epUser.getSupertokensUserId(), e.primaryUserId); |
| 188 | + } |
| 189 | + |
| 190 | + process.kill(); |
| 191 | + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); |
| 192 | + } |
| 193 | +} |
0 commit comments