diff --git a/src/roktManager.ts b/src/roktManager.ts index 0039d7d3..57a6216f 100644 --- a/src/roktManager.ts +++ b/src/roktManager.ts @@ -252,15 +252,39 @@ export default class RoktManager { } } - public hashAttributes(attributes: IRoktPartnerAttributes): Promise { - if (!this.isReady()) { - return this.deferredCall('hashAttributes', attributes); - } - + /** + * Hashes attributes and returns both original and hashed versions + * with Rokt-compatible key names (like emailsha256, mobilesha256) + * + * + * @param {IRoktPartnerAttributes} attributes - Attributes to hash + * @returns {Promise} Object with both original and hashed attributes + * + */ + public async hashAttributes(attributes: IRoktPartnerAttributes): Promise { try { - return this.kit.hashAttributes(attributes); + if (!attributes || typeof attributes !== 'object') { + return {}; + } + + const hashedAttributes: IRoktPartnerAttributes = {}; + + for (const key in attributes) { + const attributeValue = attributes[key]; + + hashedAttributes[key] = attributeValue; + + const hashedValue = await this.hashSha256(attributeValue); + + if (hashedValue) { + hashedAttributes[`${key}sha256`] = hashedValue; + } + } + + return hashedAttributes; + } catch (error) { - return Promise.reject(error instanceof Error ? error : new Error('Unknown error occurred')); + return Promise.reject(error instanceof Error ? error : new Error('Failed to hash attributes')); } } @@ -290,6 +314,29 @@ export default class RoktManager { } } + /** + * Hashes an attribute using SHA-256 + * + * @param {string | number | boolean | undefined | null} attribute - The value to hash + * @returns {Promise} SHA-256 hashed value or undefined/null + * + */ + public async hashSha256(attribute: string | number | boolean | undefined | null): Promise { + if (attribute === null || attribute === undefined) { + this.logger.warning(`hashSha256 received ${attribute} as input`); + return attribute as null | undefined; + } + + try { + const normalizedValue = String(attribute).trim().toLocaleLowerCase(); + return await this.sha256Hex(normalizedValue); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to hash "${attribute}" and returning undefined, selectPlacements will continue: ${errorMessage}`); + return undefined; + } + } + public getLocalSessionAttributes(): LocalSessionAttributes { return this.store.getLocalSessionAttributes(); } @@ -400,6 +447,37 @@ export default class RoktManager { this.messageQueue.delete(messageId); } + /** + * Hashes a string input using SHA-256 and returns the hex digest + * Uses the Web Crypto API for secure hashing + * + * @param {string} input - The string to hash + * @returns {Promise} The SHA-256 hash as a hexadecimal string + */ + private async sha256Hex(input: string): Promise { + const encoder = new TextEncoder(); + const encodedInput = encoder.encode(input); + const digest = await crypto.subtle.digest('SHA-256', encodedInput); + return this.arrayBufferToHex(digest); + } + + /** + * Converts an ArrayBuffer to a hexadecimal string representation + * Each byte is converted to a 2-character hex string with leading zeros + * + * @param {ArrayBuffer} buffer - The buffer to convert + * @returns {string} The hexadecimal string representation + */ + private arrayBufferToHex(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let hexString = ''; + for (let i = 0; i < bytes.length; i++) { + const hexByte = bytes[i].toString(16).padStart(2, '0'); + hexString += hexByte; + } + return hexString; + } + /** * Checks if an identity value has changed by comparing current and new values * diff --git a/test/jest/roktManager.spec.ts b/test/jest/roktManager.spec.ts index 69085d04..6f9ef3eb 100644 --- a/test/jest/roktManager.spec.ts +++ b/test/jest/roktManager.spec.ts @@ -76,137 +76,199 @@ describe('RoktManager', () => { }); describe('#hashAttributes', () => { + const nodeCrypto = require('crypto'); + let shaSpy: jest.SpyInstance; + beforeEach(() => { roktManager['currentUser'] = currentUser; + shaSpy = jest.spyOn(roktManager as any, 'sha256Hex'); + shaSpy.mockImplementation((s: string) => + Promise.resolve(nodeCrypto.createHash('sha256').update(s).digest('hex')), + ); }); - it('should call kit.hashAttributes with empty attributes', () => { - const kit: IRoktKit = { - launcher: { - selectPlacements: jest.fn(), - hashAttributes: jest.fn(), - use: jest.fn(), - }, - filters: undefined, - filteredUser: undefined, - hashAttributes: jest.fn(), - selectPlacements: jest.fn(), - setExtensionData: jest.fn(), - use: jest.fn(), - userAttributes: undefined, - }; - - roktManager.attachKit(kit); - - const attributes = {}; - - roktManager.hashAttributes(attributes); - expect(kit.hashAttributes).toHaveBeenCalledWith(attributes); + afterEach(() => { + shaSpy.mockRestore(); }); - it('should call kit.hashAttributes with passed in attributes', () => { - const kit: IRoktKit = { - launcher: { - selectPlacements: jest.fn(), - hashAttributes: jest.fn(), - use: jest.fn(), - }, - filters: undefined, - filteredUser: undefined, - hashAttributes: jest.fn(), - selectPlacements: jest.fn(), - setExtensionData: jest.fn(), - use: jest.fn(), - userAttributes: undefined, - }; - - roktManager.attachKit(kit); - + it('should hash attributes with passed in attributes', async () => { const attributes = { email: 'test@example.com', phone: '1234567890' }; - roktManager.hashAttributes(attributes); - expect(kit.hashAttributes).toHaveBeenCalledWith(attributes); + const result = await roktManager.hashAttributes(attributes); + + expect(result).toEqual({ + email: 'test@example.com', + emailsha256: nodeCrypto.createHash('sha256').update('test@example.com').digest('hex'), + phone: '1234567890', + phonesha256: nodeCrypto.createHash('sha256').update('1234567890').digest('hex'), + }); }); - it('should queue the hashAttributes method if no launcher or kit is attached', () => { + it('should hash all non-null string, number, and boolean values', async () => { const attributes = { - email: 'test@example.com' + email: 'Test@Example.COM', + age: 25, + active: true, + nullable: null, + undefinedVal: undefined }; - roktManager.hashAttributes(attributes); + const result = await roktManager.hashAttributes(attributes); - expect(roktManager['kit']).toBeNull(); - expect(roktManager['messageQueue'].size).toBe(1); - const queuedMessage = Array.from(roktManager['messageQueue'].values())[0]; - expect(queuedMessage.methodName).toBe('hashAttributes'); - expect(queuedMessage.payload).toBe(attributes); + expect(result).toEqual({ + email: 'Test@Example.COM', + emailsha256: nodeCrypto.createHash('sha256').update('test@example.com').digest('hex'), + age: 25, + agesha256: nodeCrypto.createHash('sha256').update('25').digest('hex'), + active: true, + activesha256: nodeCrypto.createHash('sha256').update('true').digest('hex'), + nullable: null, + undefinedVal: undefined + }); }); - it('should process queued hashAttributes calls once the launcher and kit are attached', () => { - const kit: IRoktKit = { - launcher: { - selectPlacements: jest.fn(), - hashAttributes: jest.fn(), - use: jest.fn(), - }, - filters: undefined, - filteredUser: undefined, - hashAttributes: jest.fn(), - selectPlacements: jest.fn(), - setExtensionData: jest.fn(), - use: jest.fn(), - userAttributes: undefined, - }; + it('should return empty object if attributes is null', async () => { + const result = await roktManager.hashAttributes(null as any); + expect(result).toEqual({}); + }); - const attributes = { - email: 'test@example.com' - }; + it('should return empty object if attributes is undefined', async () => { + const result = await roktManager.hashAttributes(undefined as any); + expect(result).toEqual({}); + }); - roktManager.hashAttributes(attributes); + it('should handle empty attributes object', async () => { + const result = await roktManager.hashAttributes({}); + expect(result).toEqual({}); + }); + + it('should reject if hashSha256 throws an error', async () => { + shaSpy.mockRestore(); + + // Mock hashSha256 to throw an error + const hashError = new Error('Hashing failed'); + jest.spyOn(roktManager, 'hashSha256').mockRejectedValue(hashError); + + const attributes = { email: 'test@example.com' }; + + await expect(roktManager.hashAttributes(attributes)) + .rejects + .toThrow('Hashing failed'); + }); + }); + + describe('#hashSha256', () => { + const nodeCrypto = require('crypto'); + let shaSpy: jest.SpyInstance; + + beforeEach(() => { + shaSpy = jest.spyOn(roktManager as any, 'sha256Hex'); + shaSpy.mockImplementation((s: string) => + Promise.resolve(nodeCrypto.createHash('sha256').update(s).digest('hex')), + ); + }); + + afterEach(() => { + shaSpy.mockRestore(); + }); + + it('should hash a single string value using SHA-256', async () => { + const result = await roktManager.hashSha256('test@example.com'); + const expected = nodeCrypto.createHash('sha256').update('test@example.com').digest('hex'); + + expect(result).toBe(expected); + expect(shaSpy).toHaveBeenCalledWith('test@example.com'); + expect(shaSpy).toHaveBeenCalledTimes(1); + }); + + it('should hash values without kit being attached', async () => { + // Verify kit is not attached expect(roktManager['kit']).toBeNull(); - expect(roktManager['messageQueue'].size).toBe(1); - const queuedMessage = Array.from(roktManager['messageQueue'].values())[0]; - expect(queuedMessage.methodName).toBe('hashAttributes'); - expect(queuedMessage.payload).toBe(attributes); - expect(kit.hashAttributes).not.toHaveBeenCalled(); - roktManager.attachKit(kit); - expect(roktManager['kit']).not.toBeNull(); - expect(roktManager['messageQueue'].size).toBe(0); - expect(kit.hashAttributes).toHaveBeenCalledWith(attributes); + const result = await roktManager.hashSha256('user@example.com'); + const expected = nodeCrypto.createHash('sha256').update('user@example.com').digest('hex'); + + expect(result).toBe(expected); }); - it('should pass through the correct attributes to kit.launcher.hashAttributes', async () => { - const kit: Partial = { - launcher: { - selectPlacements: jest.fn(), - hashAttributes: jest.fn(), - use: jest.fn(), - }, + it('should handle empty string', async () => { + const emptyStringHash = await roktManager.hashSha256(''); + + // Empty string after trim becomes '', hash of empty string + expect(emptyStringHash).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + }); - // We are mocking the hashAttributes method to return the - // launcher's hashAttributes method and verify that - // both the kit's and the launcher's methods - // are called with the correct attributes. - // This will happen through the Web Kit's hashAttributes method - hashAttributes: jest.fn().mockImplementation((attributes) => { - return kit.launcher.hashAttributes(attributes); - }) - }; + it('should return null and log warning when value is null', async () => { + const result = await roktManager.hashSha256(null); + + expect(result).toBeNull(); + expect(mockMPInstance.Logger.warning).toHaveBeenCalledWith('hashSha256 received null as input'); + }); - roktManager.attachKit(kit as IRoktKit); + it('should return undefined and log warning when value is undefined', async () => { + const result = await roktManager.hashSha256(undefined); + + expect(result).toBeUndefined(); + expect(mockMPInstance.Logger.warning).toHaveBeenCalledWith('hashSha256 received undefined as input'); + }); - const attributes = { - email: 'test@example.com', - phone: '1234567890' - }; + it('should return undefined and log error when hashing fails', async () => { + shaSpy.mockRejectedValue(new Error('Hash failed')); - roktManager.hashAttributes(attributes); - expect(kit.hashAttributes).toHaveBeenCalledWith(attributes); - expect(kit.launcher.hashAttributes).toHaveBeenCalledWith(attributes); + const result = await roktManager.hashSha256('test@example.com'); + + expect(result).toBeUndefined(); + expect(mockMPInstance.Logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to hash "test@example.com" and returning undefined, selectPlacements will continue') + ); + }); + + it('should hash firstName to known SHA-256 value', async () => { + const hashedFirstName = await roktManager.hashSha256('jane'); + + // Expected SHA-256 hash of 'jane' + expect(hashedFirstName).toBe('81f8f6dde88365f3928796ec7aa53f72820b06db8664f5fe76a7eb13e24546a2'); + }); + + it('should produce same hash for different case and whitespace variations', async () => { + const lowercaseEmail = await roktManager.hashSha256('jane.doe@gmail.com'); + const mixedCaseEmail = await roktManager.hashSha256('Jane.Doe@gmail.com'); + const emailWithWhitespace = await roktManager.hashSha256(' jane.doe@gmail.com '); + + // All should normalize to same hash + expect(lowercaseEmail).toBe(mixedCaseEmail); + expect(mixedCaseEmail).toBe(emailWithWhitespace); + expect(lowercaseEmail).toBe('831f6494ad6be4fcb3a724c3d5fef22d3ceffa3c62ef3a7984e45a0ea177f982'); + }); + + it('should handle numeric values and match known SHA-256', async () => { + const hashedNumber = await roktManager.hashSha256(42); + const hashedString = await roktManager.hashSha256('42'); + + // Numeric value should be converted to string and produce same hash + expect(hashedNumber).toBe(hashedString); + // Expected SHA-256 hash of '42' + expect(hashedNumber).toBe('73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049'); + }); + + it('should handle boolean values and match known SHA-256', async () => { + const hashedBoolean = await roktManager.hashSha256(true); + const hashedString = await roktManager.hashSha256('true'); + + // Boolean value should be converted to string and produce same hash + expect(hashedBoolean).toBe(hashedString); + // Expected SHA-256 hash of 'true' + expect(hashedBoolean).toBe('b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b'); + }); + + it('should hash phone number to known SHA-256 value', async () => { + const hashedPhone = await roktManager.hashSha256('1234567890'); + + // Expected SHA-256 hash of '1234567890' + expect(hashedPhone).toBe('c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646'); }); }); @@ -440,25 +502,21 @@ describe('RoktManager', () => { it('should call RoktManager methods (not kit methods directly) when processing queue', () => { // Queue some calls before kit is ready (these will be deferred) const selectOptions = { attributes: { test: 'value' } } as IRoktSelectPlacementsOptions; - const hashAttrs = { email: 'test@example.com' }; const extensionData = { 'test-ext': { config: true } }; const useName = 'TestExtension'; roktManager.selectPlacements(selectOptions); - roktManager.hashAttributes(hashAttrs); roktManager.setExtensionData(extensionData); roktManager.use(useName); // Verify calls were queued - expect(roktManager['messageQueue'].size).toBe(4); + expect(roktManager['messageQueue'].size).toBe(3); expect(kit.selectPlacements).not.toHaveBeenCalled(); // Kit methods not called yet - expect(kit.hashAttributes).not.toHaveBeenCalled(); // Kit methods not called yet expect(kit.setExtensionData).not.toHaveBeenCalled(); // Kit methods not called yet expect(kit.use).not.toHaveBeenCalled(); // Kit methods not called yet // Spy on RoktManager methods AFTER initial calls to track queue processing const selectPlacementsSpy = jest.spyOn(roktManager, 'selectPlacements'); - const hashAttributesSpy = jest.spyOn(roktManager, 'hashAttributes'); const setExtensionDataSpy = jest.spyOn(roktManager, 'setExtensionData'); const useSpy = jest.spyOn(roktManager, 'use'); @@ -469,9 +527,6 @@ describe('RoktManager', () => { expect(selectPlacementsSpy).toHaveBeenCalledTimes(1); expect(selectPlacementsSpy).toHaveBeenCalledWith(selectOptions); - expect(hashAttributesSpy).toHaveBeenCalledTimes(1); - expect(hashAttributesSpy).toHaveBeenCalledWith(hashAttrs); - expect(setExtensionDataSpy).toHaveBeenCalledTimes(1); expect(setExtensionDataSpy).toHaveBeenCalledWith(extensionData); @@ -483,7 +538,6 @@ describe('RoktManager', () => { // Clean up spies selectPlacementsSpy.mockRestore(); - hashAttributesSpy.mockRestore(); setExtensionDataSpy.mockRestore(); useSpy.mockRestore(); }); @@ -730,7 +784,7 @@ describe('RoktManager', () => { expect(kit.launcher.selectPlacements).toHaveBeenCalledWith(options); }); - it('should pass sandbox flag as an attribute through to kit.selectPlacements', ()=> { + it('should pass sandbox flag as an attribute through to kit.selectPlacements', () => { const kit: IRoktKit = { launcher: { selectPlacements: jest.fn(),