Skip to content

Commit eae3d27

Browse files
committed
feat: Add hashSha256 method for SHA-256 hashing and related test
1 parent 3cb1319 commit eae3d27

File tree

2 files changed

+159
-0
lines changed

2 files changed

+159
-0
lines changed

src/roktManager.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,28 @@ export default class RoktManager {
264264
}
265265
}
266266

267+
/**
268+
* Hashes a single value using SHA-256
269+
* Accepts the same types as IRoktPartnerAttributes values
270+
*
271+
* @param {string | number | boolean | undefined | null} attribute - The value to hash
272+
* @returns {Promise<string>} The SHA-256 hex digest of the normalized value
273+
*
274+
*/
275+
public async hashSha256(attribute: string | number | boolean | undefined | null): Promise<string> {
276+
try {
277+
if (attribute === undefined || attribute === null) {
278+
return Promise.reject(new Error('Value cannot be null or undefined'));
279+
}
280+
const normalizedValue = String(attribute).trim().toLocaleLowerCase();
281+
return await this.sha256Hex(normalizedValue);
282+
} catch (error) {
283+
const errorMessage = error instanceof Error ? error.message : String(error);
284+
this.logger.error('Failed hashSha256: ' + errorMessage);
285+
return Promise.reject(new Error(String(error)));
286+
}
287+
}
288+
267289
public setExtensionData<T>(extensionData: IRoktPartnerExtensionData<T>): void {
268290
if (!this.isReady()) {
269291
this.deferredCall<void>('setExtensionData', extensionData);
@@ -400,6 +422,37 @@ export default class RoktManager {
400422
this.messageQueue.delete(messageId);
401423
}
402424

425+
/**
426+
* Hashes a string input using SHA-256 and returns the hex digest
427+
* Uses the Web Crypto API for secure hashing
428+
*
429+
* @param {string} input - The string to hash
430+
* @returns {Promise<string>} The SHA-256 hash as a hexadecimal string
431+
*/
432+
private async sha256Hex(input: string): Promise<string> {
433+
const encoder = new TextEncoder();
434+
const encodedInput = encoder.encode(input);
435+
const digest = await crypto.subtle.digest('SHA-256', encodedInput);
436+
return this.arrayBufferToHex(digest);
437+
}
438+
439+
/**
440+
* Converts an ArrayBuffer to a hexadecimal string representation
441+
* Each byte is converted to a 2-character hex string with leading zeros
442+
*
443+
* @param {ArrayBuffer} buffer - The buffer to convert
444+
* @returns {string} The hexadecimal string representation
445+
*/
446+
private arrayBufferToHex(buffer: ArrayBuffer): string {
447+
const bytes = new Uint8Array(buffer);
448+
let hexString = '';
449+
for (let i = 0; i < bytes.length; i++) {
450+
const hexByte = bytes[i].toString(16).padStart(2, '0');
451+
hexString += hexByte;
452+
}
453+
return hexString;
454+
}
455+
403456
/**
404457
* Checks if an identity value has changed by comparing current and new values
405458
*

test/jest/roktManager.spec.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,112 @@ describe('RoktManager', () => {
210210
});
211211
});
212212

213+
describe('#hashSha256', () => {
214+
interface Hasher {
215+
sha256Hex(input: string): Promise<string>
216+
}
217+
218+
const nodeCrypto = require('crypto');
219+
let shaSpy: jest.SpyInstance;
220+
221+
beforeEach(() => {
222+
shaSpy = jest.spyOn(roktManager as unknown as Hasher, 'sha256Hex');
223+
shaSpy.mockImplementation((s: any) =>
224+
Promise.resolve(nodeCrypto.createHash('sha256').update(String(s)).digest('hex')),
225+
);
226+
});
227+
228+
afterEach(() => {
229+
shaSpy.mockRestore();
230+
});
231+
232+
it('should hash a single string value using SHA-256', async () => {
233+
const result = await roktManager.hashSha256('[email protected]');
234+
const expected = nodeCrypto.createHash('sha256').update('[email protected]').digest('hex');
235+
236+
expect(result).toBe(expected);
237+
expect(shaSpy).toHaveBeenCalledWith('[email protected]');
238+
expect(shaSpy).toHaveBeenCalledTimes(1);
239+
});
240+
241+
it('should hash values without kit being attached', async () => {
242+
// Verify kit is not attached
243+
expect(roktManager['kit']).toBeNull();
244+
245+
const result = await roktManager.hashSha256('[email protected]');
246+
const expected = nodeCrypto.createHash('sha256').update('[email protected]').digest('hex');
247+
248+
expect(result).toBe(expected);
249+
});
250+
251+
it('should handle empty string', async () => {
252+
const emptyStringHash = await roktManager.hashSha256('');
253+
254+
// Empty string after trim becomes '', hash of empty string
255+
expect(emptyStringHash).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
256+
});
257+
258+
it('should reject when value is null', async () => {
259+
await expect(roktManager.hashSha256(null)).rejects.toThrow('Value cannot be null or undefined');
260+
});
261+
262+
it('should reject when value is undefined', async () => {
263+
await expect(roktManager.hashSha256(undefined)).rejects.toThrow('Value cannot be null or undefined');
264+
});
265+
266+
it('should log error when hashing fails', async () => {
267+
shaSpy.mockRejectedValue(new Error('Hash failed'));
268+
269+
await expect(roktManager.hashSha256('[email protected]')).rejects.toThrow();
270+
expect(mockMPInstance.Logger.error).toHaveBeenCalledWith(expect.stringContaining('Failed hashSha256'));
271+
});
272+
273+
it('should hash firstName to known SHA-256 value', async () => {
274+
const hashedFirstName = await roktManager.hashSha256('jane');
275+
276+
// Expected SHA-256 hash of 'jane'
277+
expect(hashedFirstName).toBe('81f8f6dde88365f3928796ec7aa53f72820b06db8664f5fe76a7eb13e24546a2');
278+
});
279+
280+
it('should produce same hash for different case and whitespace variations', async () => {
281+
const lowercaseEmail = await roktManager.hashSha256('[email protected]');
282+
const mixedCaseEmail = await roktManager.hashSha256('[email protected]');
283+
const emailWithWhitespace = await roktManager.hashSha256(' [email protected] ');
284+
285+
// All should normalize to same hash
286+
expect(lowercaseEmail).toBe(mixedCaseEmail);
287+
expect(mixedCaseEmail).toBe(emailWithWhitespace);
288+
expect(lowercaseEmail).toBe('831f6494ad6be4fcb3a724c3d5fef22d3ceffa3c62ef3a7984e45a0ea177f982');
289+
});
290+
291+
it('should handle numeric values and match known SHA-256', async () => {
292+
const hashedNumber = await roktManager.hashSha256(42);
293+
const hashedString = await roktManager.hashSha256('42');
294+
295+
// Numeric value should be converted to string and produce same hash
296+
expect(hashedNumber).toBe(hashedString);
297+
// Expected SHA-256 hash of '42'
298+
expect(hashedNumber).toBe('73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049');
299+
});
300+
301+
it('should handle boolean values and match known SHA-256', async () => {
302+
const hashedBoolean = await roktManager.hashSha256(true);
303+
const hashedString = await roktManager.hashSha256('true');
304+
305+
// Boolean value should be converted to string and produce same hash
306+
expect(hashedBoolean).toBe(hashedString);
307+
// Expected SHA-256 hash of 'true'
308+
expect(hashedBoolean).toBe('b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b');
309+
});
310+
311+
it('should hash phone number to known SHA-256 value', async () => {
312+
const hashedPhone = await roktManager.hashSha256('1234567890');
313+
314+
// Expected SHA-256 hash of '1234567890'
315+
expect(hashedPhone).toBe('c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646');
316+
});
317+
});
318+
213319
describe('#init', () => {
214320
it('should initialize the manager with defaults when no config is provided', () => {
215321
roktManager.init(

0 commit comments

Comments
 (0)