Skip to content

Commit 47a11b1

Browse files
nan-liclaude
andcommitted
fix(iv): skip existingOnesignalId on login under IV-required
LoginHelper.switchUser was setting existingOnesignalId = currentOneSignalId when the prior user was anonymous (currentExternalId == null). Under IV-required that anon user was never created server-side — no JWT — so its onesignalId stays a local id forever. Carrying it as existingOnesignalId on the new LoginUserOperation makes canStartExecute=false stick, deadlocking the queue across logout→login cycles. Skip the merge-link entirely when useIdentityVerification == REQUIRED so the executor takes the createUser (upsert) path. Matches reference branches #2599 and #2613. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f111226 commit 47a11b1

2 files changed

Lines changed: 81 additions & 1 deletion

File tree

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.onesignal.core.internal.config.ConfigModel
44
import com.onesignal.core.internal.operations.IOperationRepo
55
import com.onesignal.debug.internal.logging.Logging
66
import com.onesignal.user.internal.identity.IdentityModelStore
7+
import com.onesignal.user.internal.jwt.JwtRequirement
78
import com.onesignal.user.internal.jwt.JwtTokenStore
89
import com.onesignal.user.internal.operations.LoginUserOperation
910

@@ -54,8 +55,18 @@ internal class LoginHelper(
5455
}
5556

5657
val newOneSignalId = identityModelStore.model.onesignalId
58+
// Under IV-required, the merge-anon-into-identified path can't dispatch — the
59+
// anon user was never created server-side (no JWT) so the local-id reference
60+
// would deadlock LoginUserOperation.canStartExecute. Skip the link entirely so
61+
// the executor takes the createUser (upsert) path.
5762
val existingOneSignalId =
58-
if (currentExternalId == null) currentOneSignalId else null
63+
if (configModel.useIdentityVerification == JwtRequirement.REQUIRED) {
64+
null
65+
} else if (currentExternalId == null) {
66+
currentOneSignalId
67+
} else {
68+
null
69+
}
5970

6071
return LoginEnqueueContext(configModel.appId, newOneSignalId, externalId, existingOneSignalId)
6172
}

OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.onesignal.debug.internal.logging.Logging
77
import com.onesignal.mocks.MockHelper
88
import com.onesignal.mocks.MockPreferencesService
99
import com.onesignal.user.internal.identity.IdentityModel
10+
import com.onesignal.user.internal.jwt.JwtRequirement
1011
import com.onesignal.user.internal.jwt.JwtTokenStore
1112
import com.onesignal.user.internal.operations.LoginUserOperation
1213
import com.onesignal.user.internal.properties.PropertiesModel
@@ -50,6 +51,7 @@ class LoginHelperTests : FunSpec({
5051
val mockOperationRepo = mockk<IOperationRepo>(relaxed = true)
5152
val mockConfigModel = mockk<ConfigModel>()
5253
every { mockConfigModel.appId } returns appId
54+
every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED
5355
val loginLock = Any()
5456

5557
val loginHelper =
@@ -91,6 +93,7 @@ class LoginHelperTests : FunSpec({
9193
val mockOperationRepo = mockk<IOperationRepo>()
9294
val mockConfigModel = mockk<ConfigModel>()
9395
every { mockConfigModel.appId } returns appId
96+
every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED
9497
val loginLock = Any()
9598

9699
val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>()
@@ -158,6 +161,7 @@ class LoginHelperTests : FunSpec({
158161
val mockOperationRepo = mockk<IOperationRepo>()
159162
val mockConfigModel = mockk<ConfigModel>()
160163
every { mockConfigModel.appId } returns appId
164+
every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED
161165
val loginLock = Any()
162166

163167
val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>()
@@ -202,6 +206,68 @@ class LoginHelperTests : FunSpec({
202206
}
203207
}
204208

209+
test("login under IV-required does NOT carry existingOnesignalId from anonymous user") {
210+
// Given - anonymous user, IV is REQUIRED. The anon user was never created server-side
211+
// (no JWT), so its onesignalId would be permanently local. Carrying it as
212+
// existingOnesignalId on the new LoginUserOperation would deadlock canStartExecute.
213+
val mockIdentityModelStore =
214+
MockHelper.identityModelStore { model ->
215+
model.externalId = null
216+
model.onesignalId = currentOneSignalId
217+
}
218+
219+
val newIdentityModel =
220+
IdentityModel().apply {
221+
externalId = newExternalId
222+
onesignalId = newOneSignalId
223+
}
224+
225+
val mockUserSwitcher = mockk<UserSwitcher>()
226+
val mockOperationRepo = mockk<IOperationRepo>()
227+
val mockConfigModel = mockk<ConfigModel>()
228+
every { mockConfigModel.appId } returns appId
229+
every { mockConfigModel.useIdentityVerification } returns JwtRequirement.REQUIRED
230+
231+
val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>()
232+
every {
233+
mockUserSwitcher.createAndSwitchToNewUser(
234+
suppressBackendOperation = any(),
235+
modify = capture(userSwitcherSlot),
236+
)
237+
} answers {
238+
userSwitcherSlot.captured(newIdentityModel, PropertiesModel())
239+
every { mockIdentityModelStore.model } returns newIdentityModel
240+
}
241+
242+
coEvery { mockOperationRepo.enqueueAndWait(any()) } returns true
243+
244+
val loginHelper =
245+
LoginHelper(
246+
identityModelStore = mockIdentityModelStore,
247+
userSwitcher = mockUserSwitcher,
248+
operationRepo = mockOperationRepo,
249+
configModel = mockConfigModel,
250+
jwtTokenStore = JwtTokenStore(MockPreferencesService()),
251+
lock = Any(),
252+
)
253+
254+
// When
255+
runBlocking {
256+
val context = loginHelper.switchUser(newExternalId, jwtBearerToken = "fresh-jwt")
257+
if (context != null) loginHelper.enqueueLogin(context)
258+
}
259+
260+
// Then — under IV, the executor must take the createUser (upsert) path; no merge link.
261+
coVerify(exactly = 1) {
262+
mockOperationRepo.enqueueAndWait(
263+
withArg<LoginUserOperation> { operation ->
264+
operation.externalId shouldBe newExternalId
265+
operation.existingOnesignalId shouldBe null
266+
},
267+
)
268+
}
269+
}
270+
205271
test("login logs error when operation fails") {
206272
// Given
207273
val mockIdentityModelStore =
@@ -220,6 +286,7 @@ class LoginHelperTests : FunSpec({
220286
val mockOperationRepo = mockk<IOperationRepo>()
221287
val mockConfigModel = mockk<ConfigModel>()
222288
every { mockConfigModel.appId } returns appId
289+
every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED
223290
val loginLock = Any()
224291

225292
val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>()
@@ -279,6 +346,7 @@ class LoginHelperTests : FunSpec({
279346
coEvery { mockOperationRepo.enqueueAndWait(any()) } returns true
280347
val mockConfigModel = mockk<ConfigModel>()
281348
every { mockConfigModel.appId } returns appId
349+
every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED
282350
val jwtTokenStore = JwtTokenStore(MockPreferencesService())
283351

284352
val loginHelper =
@@ -312,6 +380,7 @@ class LoginHelperTests : FunSpec({
312380
val mockOperationRepo = mockk<IOperationRepo>(relaxed = true)
313381
val mockConfigModel = mockk<ConfigModel>()
314382
every { mockConfigModel.appId } returns appId
383+
every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED
315384
val jwtTokenStore = JwtTokenStore(MockPreferencesService())
316385
jwtTokenStore.putJwt(currentExternalId, "old-jwt")
317386

0 commit comments

Comments
 (0)