Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
},
{
"path": "./build/releases/OneSignalSDK.page.es6.js",
"limit": "43.97 kB",
"limit": "44.10 kB",
"gzip": true
},
{
Expand Down
85 changes: 74 additions & 11 deletions src/core/operationRepo/OperationRepo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<typeof import('src/shared/helpers/general')>();
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 }]);
Expand All @@ -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 = () =>
Expand Down Expand Up @@ -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([]);
});

Expand Down
25 changes: 25 additions & 0 deletions src/core/operationRepo/OperationRepo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -113,6 +115,9 @@ export class OperationRepo implements IOperationRepo, IStartableService {
* that carries the ExecutionResult that stopped the operation.
*/
public async _enqueueAndWait(operation: Operation): Promise<void> {
if (this._shouldSuppressAnonymousOp(operation)) {
throw new OperationFailedError(ExecutionResult._Suppressed);
}
Log._debug(`OpRepo.enqueueAndWait: ${JSON.stringify(operation)}`);

await new Promise<void>((resolve, reject) => {
Expand All @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions src/shared/managers/SubscriptionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 20 additions & 9 deletions src/shared/managers/subscription/page.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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';
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';
Expand Down Expand Up @@ -53,15 +55,24 @@ async function createSubscribedUser(pushModel: SubscriptionModel): Promise<void>
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 (
Expand Down
Loading