diff --git a/package-lock.json b/package-lock.json index 345c13d59..e6113cd27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "@types/jest": "^29.5.1", "@types/jest-expect-message": "^1.1.0", "@types/mocha": "^9.0.0", - "@types/mparticle__web-sdk": "^2.16.1", + "@types/mparticle__web-sdk": "^2.66.0", "@types/node": "^20.1.0", "babel-preset-minify": "^0.5.1", "browser-sync": "^2.26.3", @@ -75,6 +75,9 @@ "uglify-js": "^3.4.9", "webpack": "^5.36.2", "webpack-cli": "^5.0.2" + }, + "peerDependencies": { + "@mparticle/event-models": "^1.1.9" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -4059,11 +4062,13 @@ "license": "MIT" }, "node_modules/@types/mparticle__web-sdk": { - "version": "2.20.2", + "version": "2.66.0", + "resolved": "https://registry.npmjs.org/@types/mparticle__web-sdk/-/mparticle__web-sdk-2.66.0.tgz", + "integrity": "sha512-+PwrdQ4Zwt2G3g0NFVfUsU+21+XZT/iiMNIvGtMB7CTu8+hJhNpJ9LiGhEJLdcTA10yZuxp14V3qMlK6eyRusQ==", "dev": true, "license": "MIT", "dependencies": { - "@mparticle/event-models": "^1.1.3" + "@mparticle/event-models": "^1.1.9" } }, "node_modules/@types/node": { @@ -13627,56 +13632,6 @@ "node": ">=18" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/node-int64": { "version": "0.4.0", "dev": true, diff --git a/package.json b/package.json index 4281518b1..fe1d39cdb 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "@types/jest": "^29.5.1", "@types/jest-expect-message": "^1.1.0", "@types/mocha": "^9.0.0", - "@types/mparticle__web-sdk": "^2.16.1", + "@types/mparticle__web-sdk": "^2.66.0", "@types/node": "^20.1.0", "babel-preset-minify": "^0.5.1", "browser-sync": "^2.26.3", diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 929e5be47..59ef35274 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -289,6 +289,48 @@ const getExpireTimestamp = (maxAge: number = ONE_DAY_IN_SECONDS): number => const parseIdentityResponse = (responseText: string): IdentityResultBody => responseText ? JSON.parse(responseText) : ({} as IdentityResultBody); +type Sha256IdentityAlias = 'email_sha256' | 'mobile_sha256'; + +const SHA256_IDENTITY_ALIASES: Readonly< + Record +> = { + email_sha256: 'other', + mobile_sha256: 'other2', +}; + +type UserIdentitiesWithAliases = UserIdentities & + Partial>; + +export const normalizeUserIdentityKeys = ( + userIdentities: UserIdentitiesWithAliases +): UserIdentities => { + const normalized: UserIdentitiesWithAliases = { ...userIdentities }; + (Object.keys(SHA256_IDENTITY_ALIASES) as Sha256IdentityAlias[]).forEach( + (alias) => { + if (alias in normalized) { + const value = normalized[alias]; + delete normalized[alias]; + normalized[SHA256_IDENTITY_ALIASES[alias]] = value; + } + } + ); + return normalized; +}; + +// Compare two identity objects by key and value, independent of +// object key insertion order. The two sides come from different sources +// (numeric IdentityType iteration vs. partner-provided / alias-normalized +// order) +const identitiesEqual = ( + a?: UserIdentities, + b?: UserIdentities +): boolean => { + const aKeys = Object.keys(a ?? {}); + const bKeys = Object.keys(b ?? {}); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((key) => a[key] === b[key]); +}; + export const hasIdentityRequestChanged = ( currentUser: IMParticleUser, newIdentityRequest: IdentityApiData @@ -300,11 +342,11 @@ export const hasIdentityRequestChanged = ( const currentUserIdentities = currentUser.getUserIdentities().userIdentities; - const newIdentities = newIdentityRequest.userIdentities; - - return ( - JSON.stringify(currentUserIdentities) !== JSON.stringify(newIdentities) + const newIdentities = normalizeUserIdentityKeys( + newIdentityRequest.userIdentities ); + + return !identitiesEqual(currentUserIdentities, newIdentities); }; /** diff --git a/src/identity.js b/src/identity.js index 99e767e19..29c9dacb9 100644 --- a/src/identity.js +++ b/src/identity.js @@ -4,6 +4,7 @@ import { cacheOrClearIdCache, createKnownIdentities, executeSearchRequest, + normalizeUserIdentityKeys, tryCacheIdentity, } from './identity-utils'; import AudienceManager from './audienceManager'; @@ -35,11 +36,21 @@ export default function Identity(mpInstance) { ); // First, remove any falsy identity values and warn about them - const cleanedIdentityApiData = mpInstance._Helpers.Validators.removeFalsyIdentityValues( + const removedFalsyIdentityData = mpInstance._Helpers.Validators.removeFalsyIdentityValues( identityApiData, mpInstance.Logger ); + // Normalize convenience aliases (email_sha256 → other, mobile_sha256 → other2) + const cleanedIdentityApiData = removedFalsyIdentityData?.userIdentities + ? { + ...removedFalsyIdentityData, + userIdentities: normalizeUserIdentityKeys( + removedFalsyIdentityData.userIdentities + ), + } + : removedFalsyIdentityData; + var identityValidationResult = mpInstance._Helpers.Validators.validateIdentities( cleanedIdentityApiData, method diff --git a/src/sdkRuntimeModels.ts b/src/sdkRuntimeModels.ts index f9b0d4c13..e31084228 100644 --- a/src/sdkRuntimeModels.ts +++ b/src/sdkRuntimeModels.ts @@ -161,9 +161,9 @@ export interface SDKProductAction { } export interface SDKProduct { - Sku?: string; - Name?: string; - Price?: number; + Sku: string; + Name: string; + Price: number; Quantity?: number; Brand?: string; Variant?: string; @@ -173,7 +173,7 @@ export interface SDKProduct { TotalAmount?: number; // https://go.mparticle.com/work/SQDSDKS-4801 - Attributes?: Record | null; + Attributes?: Record; } // https://go.mparticle.com/work/SQDSDKS-6949 @@ -306,7 +306,7 @@ export const LogLevelType = { // Currently, this extends MPConfiguration in @types/mparticle__web-sdk // and the two will be merged in once the Store module is refactored export interface SDKInitConfig - extends Omit { + extends Omit { dataPlan?: DataPlanConfig | KitBlockerDataPlan; // TODO: These should be eventually split into two different attributes logLevel?: LogLevelType; diff --git a/src/types.ts b/src/types.ts index c8eac68e9..af237e812 100644 --- a/src/types.ts +++ b/src/types.ts @@ -205,6 +205,10 @@ export const IdentityType = { return IdentityType.PhoneNumber2; case 'phone_number_3': return IdentityType.PhoneNumber3; + case 'email_sha256': + return IdentityType.Other; + case 'mobile_sha256': + return IdentityType.Other2; default: return false; } diff --git a/test/jest/identity-utils.spec.ts b/test/jest/identity-utils.spec.ts new file mode 100644 index 000000000..3ea297491 --- /dev/null +++ b/test/jest/identity-utils.spec.ts @@ -0,0 +1,169 @@ +import { + hasIdentityRequestChanged, + normalizeUserIdentityKeys, +} from '../../src/identity-utils'; +import { IMParticleUser } from '../../src/identity-user-interfaces'; + +const mockUserWithIdentities = (userIdentities: Record) => + (({ + getUserIdentities: () => ({ userIdentities }), + } as unknown) as IMParticleUser); + +describe('normalizeUserIdentityKeys', () => { + it('maps email_sha256 to other', () => { + const result = normalizeUserIdentityKeys({ email_sha256: 'abc123hash' }); + expect(result).toEqual({ other: 'abc123hash' }); + }); + + it('maps mobile_sha256 to other2', () => { + const result = normalizeUserIdentityKeys({ + mobile_sha256: 'mobilehash456', + }); + expect(result).toEqual({ other2: 'mobilehash456' }); + }); + + it('maps both aliases to distinct canonical slots', () => { + const result = normalizeUserIdentityKeys({ + email_sha256: 'emailhash', + mobile_sha256: 'mobilehash', + }); + expect(result).toEqual({ + other: 'emailhash', + other2: 'mobilehash', + }); + }); + + it('preserves other canonical identity keys alongside sha256 aliases', () => { + const result = normalizeUserIdentityKeys({ + email: 'user@example.com', + customerid: 'cust123', + email_sha256: 'sha256ofEmail', + }); + expect(result).toEqual({ + email: 'user@example.com', + customerid: 'cust123', + other: 'sha256ofEmail', + }); + }); + + it('does not modify identities without sha256 aliases', () => { + const input = { email: 'user@example.com', customerid: 'cust123' }; + const result = normalizeUserIdentityKeys(input); + expect(result).toEqual({ + email: 'user@example.com', + customerid: 'cust123', + }); + }); + + it('passes null on email_sha256 through to other (clears canonical slot)', () => { + const result = normalizeUserIdentityKeys({ email_sha256: null }); + expect(result).toEqual({ other: null }); + }); + + it('passes null on mobile_sha256 through to other2 (clears canonical slot)', () => { + const result = normalizeUserIdentityKeys({ mobile_sha256: null }); + expect(result).toEqual({ other2: null }); + }); + + it('alias overwrites a same-slot canonical value (silent last-write-wins)', () => { + const result = normalizeUserIdentityKeys({ + other: 'preexisting', + email_sha256: 'aliasvalue', + }); + expect(result).toEqual({ other: 'aliasvalue' }); + }); + + it('does not mutate the original object', () => { + const input = { email_sha256: 'hash' }; + normalizeUserIdentityKeys(input); + expect(input).toEqual({ email_sha256: 'hash' }); + }); +}); + +describe('hasIdentityRequestChanged', () => { + it('returns false when current user is null', () => { + const result = hasIdentityRequestChanged(null, { + userIdentities: { customerid: 'c' }, + }); + expect(result).toBe(false); + }); + + it('returns false when new request has no userIdentities', () => { + const user = mockUserWithIdentities({ customerid: 'c' }); + expect(hasIdentityRequestChanged(user, null)).toBe(false); + expect(hasIdentityRequestChanged(user, {} as any)).toBe(false); + }); + + it('returns false when identities match in value but differ in key order', () => { + // Persisted side comes from numeric IdentityType iteration: + // other(0), customerid(1) + const user = mockUserWithIdentities({ + other: 'hash', + customerid: 'cust123', + }); + // Partner-supplied request in different (input) order + const result = hasIdentityRequestChanged(user, { + userIdentities: { + customerid: 'cust123', + other: 'hash', + }, + }); + expect(result).toBe(false); + }); + + it('returns false when an alias normalizes to a canonical match (regression for bugbot finding)', () => { + // Persisted user has `other` set to a sha256 value at IdentityType.Other(0), + // alongside customerid at IdentityType.CustomerId(1) — so numeric iteration + // order yields { other, customerid }. + const user = mockUserWithIdentities({ + other: 'sha256ofEmail', + customerid: 'cust123', + }); + // Partner config supplies the alias form. After normalization the new + // identities historically had `other` appended at the end, causing a + // spurious mismatch. With order-independent comparison this should match. + const result = hasIdentityRequestChanged(user, { + userIdentities: { + customerid: 'cust123', + email_sha256: 'sha256ofEmail', + } as any, + }); + expect(result).toBe(false); + }); + + it('returns true when an identity value actually differs', () => { + const user = mockUserWithIdentities({ + other: 'oldhash', + customerid: 'cust123', + }); + const result = hasIdentityRequestChanged(user, { + userIdentities: { + customerid: 'cust123', + email_sha256: 'newhash', + } as any, + }); + expect(result).toBe(true); + }); + + it('returns true when the new request adds an identity', () => { + const user = mockUserWithIdentities({ customerid: 'cust123' }); + const result = hasIdentityRequestChanged(user, { + userIdentities: { + customerid: 'cust123', + email: 'user@example.com', + }, + }); + expect(result).toBe(true); + }); + + it('returns true when the new request drops an identity', () => { + const user = mockUserWithIdentities({ + customerid: 'cust123', + email: 'user@example.com', + }); + const result = hasIdentityRequestChanged(user, { + userIdentities: { customerid: 'cust123' }, + }); + expect(result).toBe(true); + }); +}); diff --git a/test/jest/types.spec.ts b/test/jest/types.spec.ts index 4cefcd21d..723811245 100644 --- a/test/jest/types.spec.ts +++ b/test/jest/types.spec.ts @@ -439,6 +439,8 @@ describe('IdentityType', () => { expect(getIdentityType('mobile_number')).toBe(MobileNumber); expect(getIdentityType('phone_number_2')).toBe(PhoneNumber2); expect(getIdentityType('phone_number_3')).toBe(PhoneNumber3); + expect(getIdentityType('email_sha256')).toBe(Other); + expect(getIdentityType('mobile_sha256')).toBe(Other2); }); it('returns false if the identity name is not found', () => { diff --git a/test/src/tests-identity-utils.ts b/test/src/tests-identity-utils.ts index 0c957760a..b2776c567 100644 --- a/test/src/tests-identity-utils.ts +++ b/test/src/tests-identity-utils.ts @@ -583,9 +583,32 @@ describe('identity-utils', () => { expect(result).to.equal(false); }); + it('returns false when SDKConfig.identifyRequest uses sha256 aliases that resolve to the stored canonical slots', () => { + const userIdentities = { + customerid: 'cust-123', + other: 'emailhash', + other2: 'mobilehash', + }; + + const newIdentities = { + customerid: 'cust-123', + email_sha256: 'emailhash', + mobile_sha256: 'mobilehash', + }; + + const mockCurrentUser = { + getUserIdentities: () => ({ + userIdentities: userIdentities, + }), + } as any; + + const result = hasIdentityRequestChanged(mockCurrentUser, { userIdentities: newIdentities }); + expect(result).to.equal(false); + }); + it('returns false when getCurrentUser() is null', () => { - const result = hasIdentityRequestChanged(null, { - userIdentities: { customerid: 'some-customer-id' } + const result = hasIdentityRequestChanged(null, { + userIdentities: { customerid: 'some-customer-id' } }); expect(result).to.equal(false); });