Skip to content

Commit 26d18e9

Browse files
authored
Merge pull request #325 from supertokens/fix/make_user_primary_fan_out
fix: make user primary fan out
2 parents a757040 + c32cfef commit 26d18e9

4 files changed

Lines changed: 199 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
99

10+
## [9.5.1]
11+
12+
- Fixes possible fan-out with makePrimary
13+
1014
## [9.5.0]
1115

1216
- Adds reservation tables: `recipe_user_account_infos`, `recipe_user_tenants`, `primary_user_tenants`

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ plugins {
33
id 'java-test-fixtures'
44
}
55

6-
version = "9.5.0"
6+
version = "9.5.1"
77

88
repositories {
99
mavenCentral()

src/main/java/io/supertokens/storage/postgresql/queries/AccountInfoQueries.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ public static boolean addPrimaryUserAccountInfo_Transaction(Start start, Connect
308308
// Insert with ON CONFLICT to catch primary key violations
309309
String QUERY = "INSERT INTO " + primaryUserTenantsTable
310310
+ " (app_id, tenant_id, account_info_type, account_info_value, primary_user_id)"
311-
+ " SELECT r.app_id, r.tenant_id, r.account_info_type, r.account_info_value, ?"
311+
+ " SELECT DISTINCT r.app_id, r.tenant_id, r.account_info_type, r.account_info_value, ?"
312312
+ " FROM " + recipeUserTenantsTable + " r"
313313
+ " INNER JOIN " + recipeUserAccountInfosTable + " ai"
314314
+ " ON r.app_id = ai.app_id"
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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

Comments
 (0)