meta-capi-param-builder-clientjs
Version:
Conversions API parameter builder for Client-side JavaScript
688 lines (624 loc) • 24.4 kB
JavaScript
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
* All rights reserved.
*
* This source code is licensed under the license found in the
* LICENSE file in the root directory of this source tree.
*/
import { getNormalizedAndHashedPII } from '../utils/piiUtil/piiUtil.js';
import { PII_DATA_TYPE } from '../model/constants.js';
// Mock the version module
jest.mock('../version.js', () => ({
getVersionInfo: jest.fn(() => ({
version: '1.0.0',
buildDate: '2025-01-01T00:00:00.000Z',
})),
}));
describe('getNormalizedAndHashedPII', () => {
// Expected appendix values for version 1.0.0
const APPENDIX_NET_NEW_STR = 'AQYCAQAA';
const APPENDIX_NO_CHANGE_STR = 'AQYAAQAA';
describe('Input validation', () => {
it('should return null for null input', () => {
expect(getNormalizedAndHashedPII(null, PII_DATA_TYPE.EMAIL)).toBeNull();
});
it('should return null for undefined input', () => {
expect(
getNormalizedAndHashedPII(undefined, PII_DATA_TYPE.EMAIL)
).toBeNull();
});
it('should return null for non-string input', () => {
const nonStringValues = [123, {}, [], true, false, 0];
nonStringValues.forEach((value) => {
expect(
getNormalizedAndHashedPII(value, PII_DATA_TYPE.EMAIL)
).toBeNull();
});
});
it('should return null for empty string input', () => {
expect(getNormalizedAndHashedPII('', PII_DATA_TYPE.EMAIL)).toBeNull();
});
it('should return null for falsy string inputs', () => {
expect(getNormalizedAndHashedPII('', PII_DATA_TYPE.EMAIL)).toBeNull();
});
});
describe('Hash detection and handling', () => {
it('should detect and return SHA-256 hashes lowercased', () => {
const sha256Hashes = [
'A665A45920422F9D417E4867EFDC4FB8A04A1F3FFF1FA07E998E86F7F7A27AE3', // uppercase
'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3', // lowercase
'A665a45920422F9d417E4867EFDC4Fb8a04a1F3FFF1fa07E998e86F7f7A27ae3', // mixed case
'ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890', // all hex chars
'0000000000000000000000000000000000000000000000000000000000000000', // all zeros
'2CF24DBA4F21D4288094E8452703C0F0142FA00B2EEB1F2C9B4E70F39E8A4C29', // SHA-256 of "hello"
'E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855', // SHA-256 of empty string
];
sha256Hashes.forEach((hash) => {
const result = getNormalizedAndHashedPII(hash, PII_DATA_TYPE.EMAIL);
expect(result).toEqual(hash.toLowerCase() + '.' + APPENDIX_NO_CHANGE_STR);
});
});
it('should detect and return MD5 hashes lowercased', () => {
const md5Hashes = [
'A665A45920422F9D417E4867EFDC4FB8', // uppercase
'a665a45920422f9d417e4867efdc4fb8', // lowercase
'A665a45920422F9d417E4867EFDC4Fb8', // mixed case
'ABCDEF1234567890ABCDEF1234567890', // all hex chars
'00000000000000000000000000000000', // all zeros
'5D41402ABC4B2A76B9719D911017C592', // MD5 of "hello"
'D41D8CD98F00B204E9800998ECF8427E', // MD5 of empty string
'098F6BCD4621D373CADE4E832627B4F6', // MD5 of "test"
];
md5Hashes.forEach((hash) => {
const result = getNormalizedAndHashedPII(hash, PII_DATA_TYPE.EMAIL);
expect(result).toEqual(hash.toLowerCase() + '.' + APPENDIX_NO_CHANGE_STR);
});
});
it('should not treat invalid hash-like strings as hashes', () => {
const testCases = [
{
input:
'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae', // 63 chars (SHA-256 - 1)
isHash: false,
},
{
input:
'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae33', // 65 chars (SHA-256 + 1)
isHash: false,
},
{
input: 'a665a45920422f9d417e4867efdc4fb', // 31 chars (MD5 - 1)
isHash: false,
},
{
input: 'a665a45920422f9d417e4867efdc4fb80', // 33 chars (MD5 + 1)
isHash: false,
},
{
input:
'g665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3', // invalid char 'g'
isHash: false,
},
{
input:
'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae!', // invalid char '!'
isHash: false,
},
{
input: 'G665A45920422F9D417E4867EFDC4FB8', // invalid char 'G' for MD5
isHash: false,
},
{
input: 'A665A45920422F9D417E4867EFDC4FB!', // invalid char '!' for MD5
isHash: false,
},
{
input: '123', // too short
isHash: false,
},
{
input: 'not-a-hash', // clearly not a hash
isHash: false,
},
{
input: '1234567890abcdef1234567890abcdef', // 32 chars, valid hex - IS treated as hash
isHash: true,
expected: '1234567890abcdef1234567890abcdef.' + APPENDIX_NO_CHANGE_STR,
},
{
input:
'1234567890123456789012345678901234567890123456789012345678901234', // 64 digits but no hex letters
isHash: false,
},
];
testCases.forEach(({ input, isHash, expected }) => {
const result = getNormalizedAndHashedPII(input, PII_DATA_TYPE.EMAIL);
if (isHash) {
expect(result).toEqual(expected);
} else if (input === '') {
expect(result).toBeNull();
} else {
// Should call getNormalizedPII instead of treating as hash
// Result might be null if getNormalizedPII returns null for invalid emails
expect(typeof result === 'string' || result === null).toBe(true);
}
});
});
it('should handle hash detection regardless of dataType', () => {
const hash =
'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3';
// Test with different data types - should always return the hash lowercased
const dataTypes = [
PII_DATA_TYPE.EMAIL,
PII_DATA_TYPE.PHONE,
PII_DATA_TYPE.FIRST_NAME,
PII_DATA_TYPE.LAST_NAME,
PII_DATA_TYPE.DATE_OF_BIRTH,
PII_DATA_TYPE.GENDER,
PII_DATA_TYPE.CITY,
PII_DATA_TYPE.STATE,
PII_DATA_TYPE.ZIP_CODE,
PII_DATA_TYPE.COUNTRY,
PII_DATA_TYPE.EXTERNAL_ID,
];
dataTypes.forEach((dataType) => {
expect(getNormalizedAndHashedPII(hash, dataType)).toEqual(
hash + '.' + APPENDIX_NO_CHANGE_STR
);
});
});
it('should handle mixed case hashes consistently', () => {
const testCases = [
{
input:
'A665A45920422F9D417E4867EFDC4FB8A04A1F3FFF1FA07E998E86F7F7A27AE3',
expected:
'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3',
},
{
input: 'A665A45920422F9D417E4867EFDC4FB8',
expected: 'a665a45920422f9d417e4867efdc4fb8',
},
{
input:
'AbCdEf1234567890AbCdEf1234567890AbCdEf1234567890AbCdEf1234567890',
expected:
'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
},
{
input: 'AbCdEf1234567890AbCdEf1234567890',
expected: 'abcdef1234567890abcdef1234567890',
},
];
testCases.forEach(({ input, expected }) => {
const result = getNormalizedAndHashedPII(input, PII_DATA_TYPE.EMAIL);
expect(result).toEqual(expected + '.' + APPENDIX_NO_CHANGE_STR);
});
});
});
describe('Fallback to getNormalizedPII and hashing', () => {
it('should call getNormalizedPII and hash the result for non-hash strings', () => {
const testCases = [
{
input: 'test@example.com',
dataType: PII_DATA_TYPE.EMAIL,
},
{
input: '123-456-7890',
dataType: PII_DATA_TYPE.PHONE,
},
{
input: 'John',
dataType: PII_DATA_TYPE.FIRST_NAME,
},
{
input: 'Smith',
dataType: PII_DATA_TYPE.LAST_NAME,
},
{
input: '12-31-1990',
dataType: PII_DATA_TYPE.DATE_OF_BIRTH,
},
{
input: 'male',
dataType: PII_DATA_TYPE.GENDER,
},
{
input: 'New York',
dataType: PII_DATA_TYPE.CITY,
},
{
input: 'California',
dataType: PII_DATA_TYPE.STATE,
},
{
input: 'United States',
dataType: PII_DATA_TYPE.COUNTRY,
},
{
input: 'user123',
dataType: PII_DATA_TYPE.EXTERNAL_ID,
},
{
input: '12345',
dataType: PII_DATA_TYPE.ZIP_CODE,
},
];
testCases.forEach(({ input, dataType }) => {
const result = getNormalizedAndHashedPII(input, dataType);
// Should return a SHA-256 hash (64 chars) + '.' + appendix(8 chars) suffix or null
if (result !== null) {
expect(typeof result).toBe('string');
expect(result).toMatch(
new RegExp(`^[a-f0-9]{64}\\.${APPENDIX_NET_NEW_STR}$`)
);
}
});
});
it('should hash the normalized result even if getNormalizedPII returns null', () => {
// When getNormalizedPII returns null, getNormalizedAndHashedPII should also return null
const result = getNormalizedAndHashedPII(
'invalid-email',
PII_DATA_TYPE.EMAIL
);
// Based on the implementation, if getNormalizedPII returns null, the function returns null
expect(result).toBeNull();
});
it('should hash empty string results from normalization', () => {
// When getNormalizedPII returns empty string, sha256_main will hash it
const result = getNormalizedAndHashedPII(
'only-letters-no-numbers',
PII_DATA_TYPE.PHONE
);
// Empty string normalization results in null
expect(result).toBeNull();
});
it('should handle null/undefined dataType by returning null', () => {
// Based on implementation, invalid dataType results in null
const result1 = getNormalizedAndHashedPII('test', null);
const result2 = getNormalizedAndHashedPII('test', undefined);
expect(result1).toBeNull();
expect(result2).toBeNull();
});
it('should handle invalid dataType by returning null', () => {
const invalidDataTypes = [
'invalid_type',
'INVALID',
'not_a_pii_type',
123,
{},
[],
];
invalidDataTypes.forEach((dataType) => {
const result = getNormalizedAndHashedPII('test', dataType);
// Invalid dataType results in null from getNormalizedPII, so function returns null
expect(result).toBeNull();
});
});
});
describe('Edge cases and boundary conditions', () => {
it('should handle boundary length hash cases', () => {
const testCases = [
{
input: 'a'.repeat(31), // 31 chars - not MD5
isHash: false,
},
{
input: 'a'.repeat(32), // 32 chars - valid MD5 if all hex
isHash: true,
expected: 'a'.repeat(32) + '.' + APPENDIX_NO_CHANGE_STR,
},
{
input: 'A'.repeat(32), // 32 chars - valid MD5 if all hex
isHash: true,
expected: 'a'.repeat(32) + '.' + APPENDIX_NO_CHANGE_STR,
},
{
input: 'a'.repeat(33), // 33 chars - not MD5
isHash: false,
},
{
input: 'a'.repeat(63), // 63 chars - not SHA-256
isHash: false,
},
{
input: 'a'.repeat(64), // 64 chars - valid SHA-256 if all hex
isHash: true,
expected: 'a'.repeat(64) + '.' + APPENDIX_NO_CHANGE_STR,
},
{
input: 'A'.repeat(64), // 64 chars - valid SHA-256 if all hex
isHash: true,
expected: 'a'.repeat(64) + '.' + APPENDIX_NO_CHANGE_STR,
},
{
input: 'a'.repeat(65), // 65 chars - not SHA-256
isHash: false,
},
];
testCases.forEach(({ input, isHash, expected }) => {
const result = getNormalizedAndHashedPII(input, PII_DATA_TYPE.EMAIL);
if (isHash) {
expect(result).toEqual(expected);
} else {
// Should be processed by getNormalizedPII, not treated as hash
// Result might be null if input is invalid for EMAIL type
expect(result !== input.toLowerCase()).toBe(true);
}
});
});
it('should handle mixed hex and non-hex characters in hash-length strings', () => {
const testCases = [
{
input: 'abcdef1234567890abcdef1234567890g', // 32 chars with invalid hex char 'g'
isHash: false,
},
{
input: 'abcdef1234567890abcdef1234567890!', // 32 chars with special char '!'
isHash: false,
},
{
input:
'abcdef1234567890abcdef1234567890ABCDEF1234567890abcdef1234567890g', // 64 chars with invalid hex
isHash: false,
},
{
input:
'abcdef1234567890abcdef1234567890ABCDEF1234567890abcdef1234567890!', // 64 chars with special char
isHash: false,
},
{
input: 'ABCDEF1234567890abcdef1234567890', // 32 chars, all valid hex
isHash: true,
expected: 'abcdef1234567890abcdef1234567890.' + APPENDIX_NO_CHANGE_STR,
},
{
input:
'ABCDEF1234567890abcdef1234567890ABCDEF1234567890abcdef1234567890', // 64 chars, all valid hex
isHash: true,
expected:
'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890.' +
APPENDIX_NO_CHANGE_STR,
},
];
testCases.forEach(({ input, isHash, expected }) => {
const result = getNormalizedAndHashedPII(input, PII_DATA_TYPE.EMAIL);
if (isHash) {
expect(result).toEqual(expected);
} else {
// Should be processed by getNormalizedPII
expect(result).not.toEqual(input.toLowerCase());
}
});
});
it('should handle all numeric hash-length strings', () => {
const testCases = [
{
input: '1'.repeat(32), // 32 digits - treated as hash since all hex chars
isHash: true,
expected: '1'.repeat(32) + '.' + APPENDIX_NO_CHANGE_STR,
},
{
input: '1'.repeat(64), // 64 digits - treated as hash since all hex chars
isHash: true,
expected: '1'.repeat(64) + '.' + APPENDIX_NO_CHANGE_STR,
},
{
input: '123456789abcdef0123456789abcdef0', // 32 chars, mixed numbers and hex
isHash: true,
expected: '123456789abcdef0123456789abcdef0.' + APPENDIX_NO_CHANGE_STR,
},
{
input:
'123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0', // 64 chars, mixed
isHash: true,
expected:
'123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0.' +
APPENDIX_NO_CHANGE_STR,
},
];
testCases.forEach(({ input, isHash, expected }) => {
const result = getNormalizedAndHashedPII(input, PII_DATA_TYPE.EMAIL);
if (isHash) {
expect(result).toEqual(expected);
} else {
// Should be processed by getNormalizedPII
expect(result).not.toEqual(input.toLowerCase());
}
});
});
it('should handle real hash examples from common algorithms', () => {
const testCases = [
{
input: '5d41402abc4b2a76b9719d911017c592', // MD5 of "hello"
expected: '5d41402abc4b2a76b9719d911017c592',
},
{
input:
'2cf24dba4f21d4288094e8452703c0f0142fa00b2eeb1f2c9b4e70f39e8a4c29', // SHA-256 of "hello"
expected:
'2cf24dba4f21d4288094e8452703c0f0142fa00b2eeb1f2c9b4e70f39e8a4c29',
},
{
input: 'd41d8cd98f00b204e9800998ecf8427e', // MD5 of empty string
expected: 'd41d8cd98f00b204e9800998ecf8427e',
},
{
input:
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', // SHA-256 of empty string
expected:
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
},
{
input: '098F6BCD4621D373CADE4E832627B4F6', // MD5 of "test" in uppercase
expected: '098f6bcd4621d373cade4e832627b4f6',
},
{
input:
'9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08', // SHA-256 of "test" in uppercase
expected:
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
},
];
testCases.forEach(({ input, expected }) => {
const result = getNormalizedAndHashedPII(input, PII_DATA_TYPE.EMAIL);
expect(result).toEqual(expected + '.' + APPENDIX_NO_CHANGE_STR);
});
});
it('should handle case sensitivity in hash detection across different data types', () => {
const mixedCaseHash =
'A665A45920422F9D417E4867EFDC4FB8a04a1f3fff1fa07e998e86f7f7a27ae3';
const expectedLowercase =
'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3';
// Test with different data types to ensure case normalization works consistently
const dataTypes = [
PII_DATA_TYPE.EMAIL,
PII_DATA_TYPE.PHONE,
PII_DATA_TYPE.FIRST_NAME,
PII_DATA_TYPE.GENDER,
PII_DATA_TYPE.CITY,
PII_DATA_TYPE.STATE,
PII_DATA_TYPE.COUNTRY,
PII_DATA_TYPE.EXTERNAL_ID,
PII_DATA_TYPE.ZIP_CODE,
];
dataTypes.forEach((dataType) => {
const result = getNormalizedAndHashedPII(mixedCaseHash, dataType);
expect(result).toEqual(expectedLowercase + '.' + APPENDIX_NO_CHANGE_STR);
});
});
it('should handle special characters and unicode in non-hash strings', () => {
const testCases = [
'José@example.com',
'test@münchen.de',
'用户@example.com',
'тест@example.com',
'test@🎉.com',
'special!@#$%chars@domain.com',
];
testCases.forEach((input) => {
const result = getNormalizedAndHashedPII(input, PII_DATA_TYPE.EMAIL);
// Should not be treated as hash - result might be null if email is invalid
expect(result).not.toEqual(input.toLowerCase());
// Don't expect result to be non-null since some of these may be invalid emails
});
});
it('should handle very long non-hash strings', () => {
const longString = 'a'.repeat(1000);
const result = getNormalizedAndHashedPII(longString, PII_DATA_TYPE.EMAIL);
// Very long strings may be rejected by email normalization, so result could be null
expect(typeof result === 'string' || result === null).toBe(true);
if (result !== null) {
expect(result).not.toEqual(longString.toLowerCase()); // Should be processed by email normalization
}
});
it('should handle concurrent calls with different inputs', () => {
const testInputs = [
{ input: 'test1@example.com', dataType: PII_DATA_TYPE.EMAIL },
{
input:
'A665A45920422F9D417E4867EFDC4FB8A04A1F3FFF1FA07E998E86F7F7A27AE3',
dataType: PII_DATA_TYPE.EMAIL,
}, // hash
{ input: 'test2@example.com', dataType: PII_DATA_TYPE.EMAIL },
{
input: '5d41402abc4b2a76b9719d911017c592',
dataType: PII_DATA_TYPE.PHONE,
}, // hash
{ input: '123-456-7890', dataType: PII_DATA_TYPE.PHONE },
];
const results = testInputs.map(({ input, dataType }) => {
return getNormalizedAndHashedPII(input, dataType);
});
// Check that hash inputs returned lowercased hashes
expect(results[1]).toEqual(
'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3.' +
APPENDIX_NO_CHANGE_STR
);
expect(results[3]).toEqual(
'5d41402abc4b2a76b9719d911017c592.' + APPENDIX_NO_CHANGE_STR
);
// Check that non-hash inputs were processed (not null)
expect(results[0]).not.toBeNull();
expect(results[2]).not.toBeNull();
expect(results[4]).not.toBeNull();
// Check that non-hash inputs were hashed to 64-char SHA-256 format + '.' + appendix(8 chars) suffix
expect(results[0]).toMatch(
new RegExp(`^[a-f0-9]{64}\\.${APPENDIX_NET_NEW_STR}$`)
);
expect(results[2]).toMatch(
new RegExp(`^[a-f0-9]{64}\\.${APPENDIX_NET_NEW_STR}$`)
);
expect(results[4]).toMatch(
new RegExp(`^[a-f0-9]{64}\\.${APPENDIX_NET_NEW_STR}$`)
);
});
it('should maintain function isolation between different PII types for hashes', () => {
const hash =
'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3';
// Call with different PII types - should all return the same lowercased hash
const emailResult = getNormalizedAndHashedPII(hash, PII_DATA_TYPE.EMAIL);
const phoneResult = getNormalizedAndHashedPII(hash, PII_DATA_TYPE.PHONE);
const nameResult = getNormalizedAndHashedPII(
hash,
PII_DATA_TYPE.FIRST_NAME
);
const genderResult = getNormalizedAndHashedPII(
hash,
PII_DATA_TYPE.GENDER
);
const cityResult = getNormalizedAndHashedPII(hash, PII_DATA_TYPE.CITY);
const stateResult = getNormalizedAndHashedPII(hash, PII_DATA_TYPE.STATE);
const countryResult = getNormalizedAndHashedPII(
hash,
PII_DATA_TYPE.COUNTRY
);
const externalIdResult = getNormalizedAndHashedPII(
hash,
PII_DATA_TYPE.EXTERNAL_ID
);
const zipResult = getNormalizedAndHashedPII(hash, PII_DATA_TYPE.ZIP_CODE);
// All should return the same lowercased hash + '.' + appendix(8 chars) suffix
expect(emailResult).toEqual(hash + '.' + APPENDIX_NO_CHANGE_STR);
expect(phoneResult).toEqual(hash + '.' + APPENDIX_NO_CHANGE_STR);
expect(nameResult).toEqual(hash + '.' + APPENDIX_NO_CHANGE_STR);
expect(genderResult).toEqual(hash + '.' + APPENDIX_NO_CHANGE_STR);
expect(cityResult).toEqual(hash + '.' + APPENDIX_NO_CHANGE_STR);
expect(stateResult).toEqual(hash + '.' + APPENDIX_NO_CHANGE_STR);
expect(countryResult).toEqual(hash + '.' + APPENDIX_NO_CHANGE_STR);
expect(externalIdResult).toEqual(hash + '.' + APPENDIX_NO_CHANGE_STR);
expect(zipResult).toEqual(hash + '.' + APPENDIX_NO_CHANGE_STR);
});
it('should handle whitespace variations in input', () => {
const testCases = [
' ', // spaces only - should return null
'\t\t\t', // tabs only - should return null
'\n\n\n', // newlines only - should return null
'\r\r\r', // carriage returns only - should return null
' \t\n\r ', // mixed whitespace - should return null
' test@example.com ', // whitespace around content - should be processed
' a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3 ', // whitespace around hash - should not be treated as hash
];
testCases.forEach((input) => {
const result = getNormalizedAndHashedPII(input, PII_DATA_TYPE.EMAIL);
if (input.trim() === '') {
expect(result).toBeNull(); // Whitespace-only should return null
} else {
// Non-empty content should be processed, but result may be null if invalid for the data type
expect(typeof result === 'string' || result === null).toBe(true);
// If input contained a hash but had whitespace, it shouldn't be treated as hash
if (
input.includes(
'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3'
)
) {
expect(result).not.toEqual(
'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3.' +
APPENDIX_NET_NEW_STR
);
}
}
});
});
});
});