Skip to content

Commit e8114da

Browse files
committed
refactor(utm): enhance UTM parameter validation to return detailed results and improve sanitization logic
1 parent 02376ca commit e8114da

File tree

4 files changed

+182
-74
lines changed

4 files changed

+182
-74
lines changed

src/models/usersFactory.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { Collection, Db } from 'mongodb';
44
import DataLoaders from '../dataLoaders';
55
import { UserDBScheme } from '@hawk.so/types';
66
import { Analytics, AnalyticsEventTypes } from '../utils/analytics';
7-
import { sanitizeUtmParams, validateUtmParams } from '../utils/utm/utm';
87

98
/**
109
* Users factory to work with User Model
@@ -79,9 +78,8 @@ export default class UsersFactory extends AbstractModelFactory<UserDBScheme, Use
7978
notifications: UserModel.generateDefaultNotificationsSettings(email),
8079
};
8180

82-
if (validateUtmParams(utm)) {
83-
const sanitizedUtm = sanitizeUtmParams(utm);
84-
userData.utm = sanitizedUtm;
81+
if (utm && Object.keys(utm).length > 0) {
82+
userData.utm = utm;
8583
}
8684

8785
const userId = (await this.collection.insertOne(userData)).insertedId;

src/resolvers/user.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { UserDBScheme } from '@hawk.so/types';
1212
import * as telegram from '../utils/telegram';
1313
import { MongoError } from 'mongodb';
1414
import { validateUtmParams, sanitizeUtmParams } from '../utils/utm/utm';
15+
import HawkCatcher from '@hawk.so/nodejs';
1516

1617
/**
1718
* See all types and fields here {@see ../typeDefs/user.graphql}
@@ -47,12 +48,34 @@ export default {
4748
{ factories }: ResolverContextBase
4849
): Promise<boolean | string> {
4950
// Validate and sanitize UTM parameters
50-
if (!validateUtmParams(utm)) {
51-
throw new UserInputError('Invalid UTM parameters provided');
51+
let sanitizedUtm;
52+
if (utm) {
53+
const validationResult = validateUtmParams(utm);
54+
55+
if (validationResult.isValid) {
56+
// All UTM parameters are valid
57+
sanitizedUtm = sanitizeUtmParams(utm, validationResult);
58+
} else if (validationResult.validKeys.length > 0) {
59+
// Some UTM parameters are valid, save only those
60+
sanitizedUtm = sanitizeUtmParams(utm, validationResult);
61+
62+
// Log the invalid keys for monitoring
63+
HawkCatcher.send(new Error('Some UTM parameters are invalid'), {
64+
email,
65+
utm,
66+
invalidKeys: JSON.stringify(validationResult.invalidKeys),
67+
validKeys: JSON.stringify(validationResult.validKeys),
68+
});
69+
} else {
70+
// No valid UTM parameters
71+
HawkCatcher.send(new Error('All UTM parameters are invalid'), {
72+
email,
73+
utm,
74+
invalidKeys: JSON.stringify(validationResult.invalidKeys),
75+
});
76+
}
5277
}
5378

54-
const sanitizedUtm = sanitizeUtmParams(utm);
55-
5679
let user;
5780

5881
try {

src/utils/utm/utm.ts

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,69 +23,96 @@ const INVALID_UTM_CHARACTERS = /[^a-zA-Z0-9\s\-_.]/g;
2323
const MAX_UTM_VALUE_LENGTH = 200;
2424

2525
/**
26-
* Validates UTM parameters
26+
* Validates UTM parameters per key
2727
* @param utm - Data form where user went to sign up. Used for analytics purposes
28-
* @returns boolean - true if valid, false if invalid
28+
* @returns object with validation results per key and overall validity
2929
*/
30-
export function validateUtmParams(utm: UserDBScheme['utm']): boolean {
30+
export function validateUtmParams(utm: UserDBScheme['utm']): {
31+
isValid: boolean;
32+
validKeys: string[];
33+
invalidKeys: string[];
34+
} {
3135
if (!utm) {
32-
return true;
36+
return { isValid: true, validKeys: [], invalidKeys: [] };
3337
}
3438

3539
// Check if utm is an object
3640
if (typeof utm !== 'object' || Array.isArray(utm)) {
37-
return false;
41+
return { isValid: false, validKeys: [], invalidKeys: ['_structure'] };
3842
}
3943

4044
const providedKeys = Object.keys(utm);
4145

4246
// Check if utm object is not empty
4347
if (providedKeys.length === 0) {
44-
return true; // Empty object is valid
48+
return { isValid: true, validKeys: [], invalidKeys: [] };
4549
}
4650

47-
// Check if all provided keys are valid UTM keys
48-
const hasInvalidKeys = providedKeys.some((key) => !VALID_UTM_KEYS.includes(key));
49-
if (hasInvalidKeys) {
50-
return false;
51-
}
51+
const validKeys: string[] = [];
52+
const invalidKeys: string[] = [];
5253

53-
// Check if values are strings and not too long
5454
for (const [key, value] of Object.entries(utm)) {
55+
// Check if key is valid UTM key
56+
if (!VALID_UTM_KEYS.includes(key)) {
57+
invalidKeys.push(key);
58+
continue;
59+
}
60+
61+
// Check if value is valid
5562
if (value !== undefined && value !== null) {
5663
if (typeof value !== 'string') {
57-
return false;
64+
invalidKeys.push(key);
65+
continue;
5866
}
5967

6068
// Check length
6169
if (value.length === 0 || value.length > MAX_UTM_VALUE_LENGTH) {
62-
return false;
70+
invalidKeys.push(key);
71+
continue;
6372
}
6473

65-
// Check for valid characters - only allow alphanumeric, spaces, hyphens, underscores, dots
74+
// Check for valid characters
6675
if (!VALID_UTM_CHARACTERS.test(value)) {
67-
return false;
76+
invalidKeys.push(key);
77+
continue;
6878
}
6979
}
80+
81+
validKeys.push(key);
7082
}
7183

72-
return true;
84+
return {
85+
isValid: invalidKeys.length === 0,
86+
validKeys,
87+
invalidKeys,
88+
};
7389
}
7490

7591
/**
76-
* Sanitizes UTM parameters by removing invalid characters
92+
* Sanitizes UTM parameters by keeping only valid keys and cleaning values
7793
* @param utm - Data form where user went to sign up. Used for analytics purposes
78-
* @returns sanitized UTM parameters or undefined if invalid
94+
* @param validationResult - Optional validation result to use valid keys from
95+
* @returns sanitized UTM parameters or undefined if no valid data
7996
*/
80-
export function sanitizeUtmParams(utm: UserDBScheme['utm']): UserDBScheme['utm'] {
97+
export function sanitizeUtmParams(
98+
utm: UserDBScheme['utm'],
99+
validationResult?: { validKeys: string[]; invalidKeys: string[] }
100+
): UserDBScheme['utm'] {
81101
if (!utm) {
82102
return undefined;
83103
}
84104

85105
const sanitized: UserDBScheme['utm'] = {};
86106

87-
for (const [key, value] of Object.entries(utm)) {
88-
if (VALID_UTM_KEYS.includes(key) && value && typeof value === 'string') {
107+
// Use validation result if provided, otherwise validate inline
108+
const keysToProcess = validationResult
109+
? validationResult.validKeys
110+
: Object.keys(utm).filter((key) => VALID_UTM_KEYS.includes(key));
111+
112+
for (const key of keysToProcess) {
113+
const value = (utm as any)[key];
114+
115+
if (value && typeof value === 'string') {
89116
// Sanitize value: keep only allowed characters and limit length
90117
const cleanValue = value
91118
.replace(INVALID_UTM_CHARACTERS, '')

test/utils/utm.test.ts

Lines changed: 104 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,84 +2,144 @@ import { validateUtmParams, sanitizeUtmParams } from '../../src/utils/utm/utm';
22

33
describe('UTM Utils', () => {
44
describe('validateUtmParams', () => {
5-
it('should return true for undefined or null utm', () => {
6-
expect(validateUtmParams(undefined)).toBe(true);
7-
expect(validateUtmParams(null as any)).toBe(true);
5+
it('should return valid result for undefined or null utm', () => {
6+
expect(validateUtmParams(undefined)).toEqual({
7+
isValid: true,
8+
validKeys: [],
9+
invalidKeys: [],
10+
});
11+
expect(validateUtmParams(null as any)).toEqual({
12+
isValid: true,
13+
validKeys: [],
14+
invalidKeys: [],
15+
});
816
});
917

10-
it('should return true for empty object', () => {
11-
expect(validateUtmParams({})).toBe(true);
18+
it('should return valid result for empty object', () => {
19+
expect(validateUtmParams({})).toEqual({ isValid: true, validKeys: [], invalidKeys: [] });
1220
});
1321

14-
it('should return false for non-object types', () => {
15-
expect(validateUtmParams('string' as any)).toBe(false);
16-
expect(validateUtmParams(123 as any)).toBe(false);
17-
expect(validateUtmParams(true as any)).toBe(false);
18-
expect(validateUtmParams([] as any)).toBe(false);
22+
it('should return invalid result for non-object types', () => {
23+
expect(validateUtmParams('string' as any)).toEqual({
24+
isValid: false,
25+
validKeys: [],
26+
invalidKeys: ['_structure'],
27+
});
28+
expect(validateUtmParams(123 as any)).toEqual({
29+
isValid: false,
30+
validKeys: [],
31+
invalidKeys: ['_structure'],
32+
});
33+
expect(validateUtmParams(true as any)).toEqual({
34+
isValid: false,
35+
validKeys: [],
36+
invalidKeys: ['_structure'],
37+
});
38+
expect(validateUtmParams([] as any)).toEqual({
39+
isValid: false,
40+
validKeys: [],
41+
invalidKeys: ['_structure'],
42+
});
1943
});
2044

21-
it('should return false for invalid UTM keys', () => {
22-
expect(validateUtmParams({ invalidKey: 'value' } as any)).toBe(false);
23-
expect(validateUtmParams({ source: 'google', invalidKey: 'value' } as any)).toBe(false);
45+
it('should identify invalid UTM keys', () => {
46+
const result1 = validateUtmParams({ invalidKey: 'value' } as any);
47+
expect(result1.isValid).toBe(false);
48+
expect(result1.invalidKeys).toContain('invalidKey');
49+
expect(result1.validKeys).toEqual([]);
50+
51+
const result2 = validateUtmParams({ source: 'google', invalidKey: 'value' } as any);
52+
expect(result2.isValid).toBe(false);
53+
expect(result2.invalidKeys).toContain('invalidKey');
54+
expect(result2.validKeys).toContain('source');
2455
});
2556

26-
it('should return true for valid UTM keys', () => {
27-
expect(validateUtmParams({ source: 'google' })).toBe(true);
28-
expect(validateUtmParams({ medium: 'cpc' })).toBe(true);
29-
expect(validateUtmParams({ campaign: 'spring_2025' })).toBe(true);
30-
expect(validateUtmParams({ content: 'ad_variant_a' })).toBe(true);
31-
expect(validateUtmParams({ term: 'error_tracker' })).toBe(true);
57+
it('should return valid result for valid UTM keys', () => {
58+
const result1 = validateUtmParams({ source: 'google' });
59+
expect(result1.isValid).toBe(true);
60+
expect(result1.validKeys).toContain('source');
61+
expect(result1.invalidKeys).toEqual([]);
62+
63+
const result2 = validateUtmParams({ medium: 'cpc' });
64+
expect(result2.isValid).toBe(true);
65+
expect(result2.validKeys).toContain('medium');
3266
});
3367

34-
it('should return true for multiple valid UTM keys', () => {
68+
it('should validate multiple UTM keys correctly', () => {
3569
const validUtm = {
3670
source: 'google',
3771
medium: 'cpc',
3872
campaign: 'spring_2025_launch',
3973
content: 'ad_variant_a',
4074
term: 'error_tracker',
4175
};
42-
expect(validateUtmParams(validUtm)).toBe(true);
76+
const result = validateUtmParams(validUtm);
77+
expect(result.isValid).toBe(true);
78+
expect(result.validKeys).toEqual(['source', 'medium', 'campaign', 'content', 'term']);
79+
expect(result.invalidKeys).toEqual([]);
4380
});
4481

45-
it('should return false for non-string values', () => {
46-
expect(validateUtmParams({ source: 123 } as any)).toBe(false);
47-
expect(validateUtmParams({ source: true } as any)).toBe(false);
48-
expect(validateUtmParams({ source: {} } as any)).toBe(false);
49-
expect(validateUtmParams({ source: [] } as any)).toBe(false);
82+
it('should identify non-string values as invalid', () => {
83+
const result1 = validateUtmParams({ source: 123 } as any);
84+
expect(result1.isValid).toBe(false);
85+
expect(result1.invalidKeys).toContain('source');
86+
87+
const result2 = validateUtmParams({ source: 'google', medium: true } as any);
88+
expect(result2.isValid).toBe(false);
89+
expect(result2.validKeys).toContain('source');
90+
expect(result2.invalidKeys).toContain('medium');
5091
});
5192

52-
it('should return false for empty string values', () => {
53-
expect(validateUtmParams({ source: '' })).toBe(false);
93+
it('should identify empty string values as invalid', () => {
94+
const result = validateUtmParams({ source: '' });
95+
expect(result.isValid).toBe(false);
96+
expect(result.invalidKeys).toContain('source');
5497
});
5598

56-
it('should return false for values that are too long', () => {
99+
it('should identify values that are too long as invalid', () => {
57100
const longValue = 'a'.repeat(201);
58-
expect(validateUtmParams({ source: longValue })).toBe(false);
101+
const result = validateUtmParams({ source: longValue });
102+
expect(result.isValid).toBe(false);
103+
expect(result.invalidKeys).toContain('source');
59104
});
60105

61-
it('should return true for values at maximum length', () => {
106+
it('should accept values at maximum length', () => {
62107
const maxLengthValue = 'a'.repeat(200);
63-
expect(validateUtmParams({ source: maxLengthValue })).toBe(true);
108+
const result = validateUtmParams({ source: maxLengthValue });
109+
expect(result.isValid).toBe(true);
110+
expect(result.validKeys).toContain('source');
111+
});
112+
113+
it('should identify values with invalid characters', () => {
114+
const result = validateUtmParams({ source: 'google@example' });
115+
expect(result.isValid).toBe(false);
116+
expect(result.invalidKeys).toContain('source');
64117
});
65118

66-
it('should return false for values with invalid characters', () => {
67-
expect(validateUtmParams({ source: 'google@example' })).toBe(false);
68-
expect(validateUtmParams({ source: 'google#hash' })).toBe(false);
69-
expect(validateUtmParams({ source: 'google$money' })).toBe(false);
70-
expect(validateUtmParams({ source: 'google%percent' })).toBe(false);
119+
it('should accept values with valid characters', () => {
120+
const result = validateUtmParams({ source: 'google-ads' });
121+
expect(result.isValid).toBe(true);
122+
expect(result.validKeys).toContain('source');
71123
});
72124

73-
it('should return true for values with valid characters', () => {
74-
expect(validateUtmParams({ source: 'google-ads' })).toBe(true);
75-
expect(validateUtmParams({ source: 'google_ads' })).toBe(true);
76-
expect(validateUtmParams({ source: 'google.com' })).toBe(true);
77-
expect(validateUtmParams({ source: 'Google Ads 123' })).toBe(true);
125+
it('should handle mixed valid and invalid keys', () => {
126+
const input = {
127+
source: 'google',
128+
medium: 'invalid@chars',
129+
campaign: 'valid_campaign',
130+
invalidKey: 'value',
131+
} as any;
132+
const result = validateUtmParams(input);
133+
expect(result.isValid).toBe(false);
134+
expect(result.validKeys).toEqual(['source', 'campaign']);
135+
expect(result.invalidKeys).toEqual(['medium', 'invalidKey']);
78136
});
79137

80138
it('should handle undefined and null values in object', () => {
81-
expect(validateUtmParams({ source: 'google', medium: undefined })).toBe(true);
82-
expect(validateUtmParams({ source: 'google', medium: null as any })).toBe(true);
139+
const result = validateUtmParams({ source: 'google', medium: undefined });
140+
expect(result.isValid).toBe(true);
141+
expect(result.validKeys).toContain('source');
142+
expect(result.validKeys).toContain('medium');
83143
});
84144
});
85145

0 commit comments

Comments
 (0)