From 106ccee34832254217c84e7588969f27c40bb3c5 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Thu, 17 Sep 2026 14:02:44 -0700 Subject: [PATCH] feat: drop anonymous operations at enqueue when Identity Verification is active When the app requires a JWT, an anonymous operation can never pass the dispatch gate because an anonymous user has no token. Drop it at enqueue time and warn the developer instead of holding it in the queue forever. - OperationRepo._shouldSuppressAnonymousOp runs in _enqueue and _enqueueAndWait, behind the isIvCodePathEnabled outer gate - LoginUserOperation is exempt so login and the push grant still queue it - _enqueueAndWait rejects with OperationFailedError(_Suppressed) - The push grant catches the _Suppressed rejection and keeps the model local, so an anonymous visitor under IV does not see an error - The warning uses console.warn so it shows in production builds --- package.json | 2 +- src/core/operationRepo/OperationRepo.test.ts | 85 ++++++++++++++++--- src/core/operationRepo/OperationRepo.ts | 25 ++++++ .../managers/SubscriptionManager.test.ts | 25 ++++++ src/shared/managers/subscription/page.ts | 29 +++++-- 5 files changed, 145 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index d0ba5f04f..287266ce7 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "43.97 kB", + "limit": "44.10 kB", "gzip": true }, { diff --git a/src/core/operationRepo/OperationRepo.test.ts b/src/core/operationRepo/OperationRepo.test.ts index e4224a506..18cd48c03 100644 --- a/src/core/operationRepo/OperationRepo.test.ts +++ b/src/core/operationRepo/OperationRepo.test.ts @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, test, vi, type Mock } from 'vi import { JwtTokenStore } from '../JwtTokenStore'; import { OperationModelStore } from '../modelRepo/OperationModelStore'; import { CreateSubscriptionOperation } from '../operations/CreateSubscriptionOperation'; +import { LoginUserOperation } from '../operations/LoginUserOperation'; import { GroupComparisonType, type GroupComparisonValue, @@ -21,7 +22,7 @@ import { SetAliasOperation } from '../operations/SetAliasOperation'; import { ExecutionResult, type IOperationExecutor } from '../types/operation'; import { OP_REPO_POST_CREATE_DELAY } from './constants'; import { NewRecordsState } from './NewRecordsState'; -import { OperationRepo } from './OperationRepo'; +import { OperationRepo, type OperationQueueItem } from './OperationRepo'; vi.mock('src/shared/helpers/general', async (importOriginal) => { const mod = await importOriginal(); @@ -259,16 +260,24 @@ describe('OperationRepo', () => { }; const anonymousOp = () => new Operation('anon'); const identifiedOp = (externalId = EXTERNAL_ID) => ownedBy(new Operation('owned'), externalId); + // Places an operation in the queue the way _loadSavedOperations does, past the + // enqueue-time suppression, to model a row persisted before IV was turned on. + const loadIntoQueue = (op: Operation, resolver?: OperationQueueItem['resolver']) => { + const item: OperationQueueItem = { operation: op, bucket: 0, retries: 0 }; + if (resolver) item.resolver = resolver; + opRepo._queue.push(item); + mockOperationModelStore._add(op); + return item; + }; describe('IV behavior active', () => { beforeEach(() => setJwtRequirement(JwtRequirement._Required)); - test('an anonymous operation is skipped and stays queued', () => { - const op = anonymousOp(); - opRepo._enqueue(op); + test('a loaded anonymous operation is skipped and stays queued', () => { + const item = loadIntoQueue(anonymousOp()); expect(opRepo._getNextOps(0)).toBeNull(); - expect(opRepo._queue).toEqual([{ operation: op, bucket: 0, retries: 0 }]); + expect(opRepo._queue).toEqual([item]); }); test('an identified operation with no stored token is skipped, not dropped', () => { @@ -306,7 +315,7 @@ describe('OperationRepo', () => { return false; } } - const op = new NoJwtOperation('no-jwt'); + const op = ownedBy(new NoJwtOperation('no-jwt'), EXTERNAL_ID); opRepo._enqueue(op); expect(opRepo._getNextOps(0)).toEqual([{ operation: op, bucket: 0, retries: 0 }]); @@ -325,6 +334,58 @@ describe('OperationRepo', () => { }); }); + describe('anonymous operation suppression at enqueue', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + describe('IV behavior active', () => { + beforeEach(() => setJwtRequirement(JwtRequirement._Required)); + + test('_enqueue drops an anonymous operation and warns', () => { + opRepo._enqueue(anonymousOp()); + + expect(opRepo._queue).toEqual([]); + expect(mockOperationModelStore._list()).toEqual([]); + expect(warn).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining('mock-op was dropped. Identity Verification is on'), + ); + }); + + test('_enqueueAndWait rejects an anonymous operation as suppressed', async () => { + const error = await opRepo._enqueueAndWait(anonymousOp()).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(OperationFailedError); + expect((error as OperationFailedError)._result).toBe(ExecutionResult._Suppressed); + expect(opRepo._queue).toEqual([]); + }); + + test('an anonymous LoginUserOperation is exempt', () => { + const op = new LoginUserOperation(APP_ID, ONESIGNAL_ID); + opRepo._enqueue(op); + + expect(opRepo._queue).toEqual([{ operation: op, bucket: 0, retries: 0 }]); + expect(warn).not.toHaveBeenCalled(); + }); + + test('an identified operation is queued', () => { + const op = identifiedOp(); + opRepo._enqueue(op); + + expect(opRepo._queue).toEqual([{ operation: op, bucket: 0, retries: 0 }]); + expect(warn).not.toHaveBeenCalled(); + }); + }); + + test('IV behavior inactive with the new code path on: anonymous operations are queued', () => { + setJwtRequirement(JwtRequirement._NotRequired); + localStorage.setItem('os_feature_overrides', 'sdk_identity_verification'); + const op = anonymousOp(); + opRepo._enqueue(op); + + expect(opRepo._queue).toEqual([{ operation: op, bucket: 0, retries: 0 }]); + expect(warn).not.toHaveBeenCalled(); + }); + }); + describe('FailUnauthorized', () => { const invalidated = vi.fn(); const failUnauthorized = () => @@ -403,13 +464,15 @@ describe('OperationRepo', () => { setJwtRequirement(JwtRequirement._Required); failUnauthorized(); - // Anonymous operations never dispatch under IV, so drive the executor directly. - const op = anonymousOp(); - const waiter = rejectionOf(opRepo._enqueueAndWait(op)); - await opRepo._executeOperations([opRepo._queue[0]]); + // A loaded anonymous operation never passes the gate, so drive the executor directly. + const resolver = vi.fn(); + const item = loadIntoQueue(anonymousOp(), resolver); + opRepo._queue.length = 0; + await opRepo._executeOperations([item]); - expect((await waiter)._result).toBe(ExecutionResult._FailUnauthorized); + expect(resolver).toHaveBeenCalledExactlyOnceWith(false, ExecutionResult._FailUnauthorized); expect(invalidated).not.toHaveBeenCalled(); + expect(opRepo._queue).toEqual([]); expect(mockOperationModelStore._list()).toEqual([]); }); diff --git a/src/core/operationRepo/OperationRepo.ts b/src/core/operationRepo/OperationRepo.ts index 9a25edf8c..2fe6016c5 100644 --- a/src/core/operationRepo/OperationRepo.ts +++ b/src/core/operationRepo/OperationRepo.ts @@ -17,6 +17,7 @@ import { } from '../identityVerification'; import { type JwtTokenStore } from '../JwtTokenStore'; import { type OperationModelStore } from '../modelRepo/OperationModelStore'; +import { LoginUserOperation } from '../operations/LoginUserOperation'; import { GroupComparisonType, type Operation } from '../operations/Operation'; import { OP_REPO_DEFAULT_FAIL_RETRY_BACKOFF, @@ -96,6 +97,7 @@ export class OperationRepo implements IOperationRepo, IStartableService { } public _enqueue(operation: Operation): void { + if (this._shouldSuppressAnonymousOp(operation)) return; Log._debug(`OpRepo.enqueue: ${JSON.stringify(operation)}`); this._internalEnqueue( @@ -113,6 +115,9 @@ export class OperationRepo implements IOperationRepo, IStartableService { * that carries the ExecutionResult that stopped the operation. */ public async _enqueueAndWait(operation: Operation): Promise { + if (this._shouldSuppressAnonymousOp(operation)) { + throw new OperationFailedError(ExecutionResult._Suppressed); + } Log._debug(`OpRepo.enqueueAndWait: ${JSON.stringify(operation)}`); await new Promise((resolve, reject) => { @@ -129,6 +134,26 @@ export class OperationRepo implements IOperationRepo, IStartableService { }); } + /** + * An anonymous operation can never dispatch while IV behavior is active: the gate + * needs a token and an anonymous user has none. Drop it at enqueue instead of + * holding it forever. LoginUserOperation is exempt; login and the push grant + * enqueue it on purpose, and the load-time purge removes a stale one. + * Outer gate isIvCodePathEnabled keeps the legacy enqueue path unchanged when + * the flag is off. + */ + private _shouldSuppressAnonymousOp(op: Operation): boolean { + if (!isIvCodePathEnabled()) return false; + if (op instanceof LoginUserOperation) return false; + if (!isIvBehaviorActive() || op._externalId) return false; + + // Bypasses Log so the developer sees this in production builds. + console.warn( + `OneSignal: ${op._name} was dropped. Identity Verification is on and no user is logged in. Call login(externalId, jwt) first.`, + ); + return true; + } + private _internalEnqueue( queueItem: OperationQueueItem, addToStore: boolean, diff --git a/src/shared/managers/SubscriptionManager.test.ts b/src/shared/managers/SubscriptionManager.test.ts index b361b9760..21fe9f9de 100644 --- a/src/shared/managers/SubscriptionManager.test.ts +++ b/src/shared/managers/SubscriptionManager.test.ts @@ -71,6 +71,31 @@ describe('SubscriptionManager', () => { }); }); + test('under Identity Verification, an anonymous push grant keeps the model local and sends nothing', async () => { + TestEnvironment.initialize({ overrideServerConfig: { config: { jwt_required: true } } }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const rawSubscription = getRawPushSubscription(); + await setPushToken(rawSubscription.w3cEndpoint?.toString()); + + // Lenient: the grant does not reject. + await expect( + updatePushSubscriptionModelWithRawSubscription(rawSubscription), + ).resolves.toBeUndefined(); + + const subModels = OneSignal._coreDirector._subscriptionModelStore._list(); + expect(subModels.length).toBe(1); + expect(IDManager._isLocalId(subModels[0].id)).toBe(true); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('create-subscription was dropped. Identity Verification is on'), + ); + + // The anonymous login operation is exempt and waits for login; no request is sent. + const queue = OneSignal._coreDirector._operationRepo._queue; + expect(queue.map((item) => item.operation._name)).toEqual(['login-user']); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(createUserFn).not.toHaveBeenCalled(); + }); + test('should create user if push subscription model has a local id', async () => { const generatePushSubscriptionModelSpy = vi.spyOn( OneSignal._coreDirector, diff --git a/src/shared/managers/subscription/page.ts b/src/shared/managers/subscription/page.ts index bebf4ce85..8e4e79e90 100644 --- a/src/shared/managers/subscription/page.ts +++ b/src/shared/managers/subscription/page.ts @@ -1,6 +1,7 @@ import type { SubscriptionModel } from 'src/core/models/SubscriptionModel'; import { CreateSubscriptionOperation } from 'src/core/operations/CreateSubscriptionOperation'; import { LoginUserOperation } from 'src/core/operations/LoginUserOperation'; +import { ExecutionResult } from 'src/core/types/operation'; import LoginManager from 'src/page/managers/LoginManager'; import FuturePushSubscriptionRecord from 'src/page/userModel/FuturePushSubscriptionRecord'; import type { ContextInterface } from 'src/shared/context/types'; @@ -8,6 +9,7 @@ import { getSubscription } from 'src/shared/database/subscription'; import { getOneSignalApiUrl, useSafariLegacyPush } from 'src/shared/environment/detect'; import { MissingSafariWebIdError, + OperationFailedError, PermissionBlockedError, SWRegistrationError, } from 'src/shared/errors/common'; @@ -53,15 +55,24 @@ async function createSubscribedUser(pushModel: SubscriptionModel): Promise OneSignal._coreDirector._operationRepo._enqueue( new LoginUserOperation(appId, identityModel._onesignalId, identityModel._externalId), ); - await OneSignal._coreDirector._operationRepo._enqueueAndWait( - new CreateSubscriptionOperation({ - ...pushModel.toJSON(), - appId, - onesignalId: identityModel._onesignalId, - externalId: identityModel._externalId, - subscriptionId: pushModel.id!, - }), - ); + try { + await OneSignal._coreDirector._operationRepo._enqueueAndWait( + new CreateSubscriptionOperation({ + ...pushModel.toJSON(), + appId, + onesignalId: identityModel._onesignalId, + externalId: identityModel._externalId, + subscriptionId: pushModel.id!, + }), + ); + } catch (e) { + // Under Identity Verification an anonymous visitor has no backend user yet. The + // push model stays local and folds into the create-user request at login. + if (e instanceof OperationFailedError && e._result === ExecutionResult._Suppressed) { + return; + } + throw e; + } } export const updatePushSubscriptionModelWithRawSubscription = async (