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 (