From a757684b11c37037b3de242013a40496d30ae002 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 6 Aug 2025 16:36:36 +0300 Subject: [PATCH 01/21] feat(user): add UTM parameters support in user creation and sign-up --- src/models/usersFactory.ts | 13 ++++++++++--- src/resolvers/user.ts | 5 +++-- src/typeDefs/user.ts | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/models/usersFactory.ts b/src/models/usersFactory.ts index ffa7f249..85b5d93d 100644 --- a/src/models/usersFactory.ts +++ b/src/models/usersFactory.ts @@ -62,16 +62,23 @@ export default class UsersFactory extends AbstractModelFactory { - const generatedPassword = password || await UserModel.generatePassword(); + public async create(email: string, password?: string, utm?: any): Promise { + const generatedPassword = password || (await UserModel.generatePassword()); const hashedPassword = await UserModel.hashPassword(generatedPassword); - const userData = { + const userData: any = { email, password: hashedPassword, notifications: UserModel.generateDefaultNotificationsSettings(email), }; + + // Add UTM data if provided + if (utm && Object.keys(utm).length > 0) { + userData.utm = utm; + } + const userId = (await this.collection.insertOne(userData)).insertedId; const user = new UserModel({ diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index bfe663c5..39ca0185 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -37,17 +37,18 @@ export default { * Register user with provided email * @param _obj - parent object (undefined for this resolver) * @param email - user email + * @param utm - UTM parameters * @param factories - factories for working with models */ async signUp( _obj: undefined, - { email }: {email: string}, + { email, utm }: { email: string; utm?: any }, { factories }: ResolverContextBase ): Promise { let user; try { - user = await factories.usersFactory.create(email); + user = await factories.usersFactory.create(email, undefined, utm); const password = user.generatedPassword!; diff --git a/src/typeDefs/user.ts b/src/typeDefs/user.ts index aa82cc4a..2d8aa25b 100644 --- a/src/typeDefs/user.ts +++ b/src/typeDefs/user.ts @@ -2,6 +2,36 @@ import { gql } from 'apollo-server-express'; import isE2E from '../utils/isE2E'; export default gql` + """ + UTM parameters input type + """ + input UtmInput { + """ + UTM source + """ + source: String + + """ + UTM medium + """ + medium: String + + """ + UTM campaign + """ + campaign: String + + """ + UTM content + """ + content: String + + """ + UTM term + """ + term: String + } + """ Authentication token """ @@ -72,6 +102,11 @@ export default gql` Registration email """ email: String! @validate(isEmail: true) + + """ + UTM parameters + """ + utm: UtmInput ): ${isE2E ? 'String!' : 'Boolean!'} """ From ece94f6d4bc171fda4cd76d5d45f722202d91a0d Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 Aug 2025 13:40:22 +0000 Subject: [PATCH 02/21] Bump version up to 1.1.30 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f9b32ca1..c1cbfe13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.1.29", + "version": "1.1.30", "main": "index.ts", "license": "UNLICENSED", "scripts": { From a4a79038aca7ae6ee390d72bbc465c1b68e78807 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 6 Aug 2025 16:57:46 +0300 Subject: [PATCH 03/21] docs(user): clarify UTM parameter description in user creation and sign-up methods --- src/models/usersFactory.ts | 2 +- src/resolvers/user.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/models/usersFactory.ts b/src/models/usersFactory.ts index 85b5d93d..ca5bb544 100644 --- a/src/models/usersFactory.ts +++ b/src/models/usersFactory.ts @@ -62,7 +62,7 @@ export default class UsersFactory extends AbstractModelFactory { const generatedPassword = password || (await UserModel.generatePassword()); diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 39ca0185..47089c7b 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -37,7 +37,7 @@ export default { * Register user with provided email * @param _obj - parent object (undefined for this resolver) * @param email - user email - * @param utm - UTM parameters + * @param utm - Data form where user went to sign up. Used for analytics purposes * @param factories - factories for working with models */ async signUp( From 9ab01e5d10d60eb66829a8ec066740f6b0ff84e1 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 6 Aug 2025 17:31:55 +0300 Subject: [PATCH 04/21] chore(deps): update @hawk.so/types to version 0.1.33 and enhance user model with UTM parameter support --- package.json | 2 +- src/models/user.ts | 7 ++++++- src/models/usersFactory.ts | 6 +++++- src/resolvers/user.ts | 2 +- yarn.lock | 8 ++++---- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index c1cbfe13..f31f1b1e 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@graphql-tools/schema": "^8.5.1", "@graphql-tools/utils": "^8.9.0", "@hawk.so/nodejs": "^3.1.1", - "@hawk.so/types": "^0.1.31", + "@hawk.so/types": "^0.1.33", "@types/amqp-connection-manager": "^2.0.4", "@types/bson": "^4.0.5", "@types/debug": "^4.1.5", diff --git a/src/models/user.ts b/src/models/user.ts index b6e9cb8c..7ea2f065 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -133,7 +133,12 @@ export default class UserModel extends AbstractModel implements Us /** * Saved bank cards for one-click payments */ - public bankCards?: BankCard[] + public bankCards?: BankCard[]; + + /** + * UTM parameters from signup - Data form where user went to sign up. Used for analytics purposes + */ + public utm?: UserDBScheme['utm']; /** * Model's collection diff --git a/src/models/usersFactory.ts b/src/models/usersFactory.ts index ca5bb544..2b76d8dd 100644 --- a/src/models/usersFactory.ts +++ b/src/models/usersFactory.ts @@ -64,7 +64,11 @@ export default class UsersFactory extends AbstractModelFactory { + public async create( + email: string, + password?: string, + utm?: UserDBScheme['utm'] + ): Promise { const generatedPassword = password || (await UserModel.generatePassword()); const hashedPassword = await UserModel.hashPassword(generatedPassword); diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 47089c7b..6468d83f 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -42,7 +42,7 @@ export default { */ async signUp( _obj: undefined, - { email, utm }: { email: string; utm?: any }, + { email, utm }: { email: string; utm?: UserDBScheme['utm'] }, { factories }: ResolverContextBase ): Promise { let user; diff --git a/yarn.lock b/yarn.lock index 9d2d9361..db3bac38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -458,10 +458,10 @@ dependencies: "@types/mongodb" "^3.5.34" -"@hawk.so/types@^0.1.31": - version "0.1.31" - resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.1.31.tgz#fba2c3451e927558bfcc3b1d942baaf8e72ad214" - integrity sha512-o1LeA3JVIUPRSIZegKwAdl4noQ1KYxwr80eisJMlghP9knu6PbYw20rIMyan5qQ3epOWs8gO1CU3iwHZprFiCg== +"@hawk.so/types@^0.1.33": + version "0.1.33" + resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.1.33.tgz#feb077b699b3e0001552588a372e1efe6cd58f40" + integrity sha512-q3AdVxzQ8Qk8qyYiAcAacxNZXWTG/oLmVpjQlcLm2Eh5OJgpaZvH8hQCeRQ/ml1cqbYW8gUrRbMMCS2QOcwxEw== dependencies: "@types/mongodb" "^3.5.34" From 33087eec186aa431d1446e151b6c8fcf195b0708 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 6 Aug 2025 17:45:30 +0300 Subject: [PATCH 05/21] feat(user): validate and sanitize UTM parameters during user creation --- src/models/usersFactory.ts | 2 +- src/resolvers/user.ts | 10 +++++- src/utils/analytics/index.ts | 1 + src/utils/analytics/utm.ts | 62 ++++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 src/utils/analytics/utm.ts diff --git a/src/models/usersFactory.ts b/src/models/usersFactory.ts index 2b76d8dd..8032bb30 100644 --- a/src/models/usersFactory.ts +++ b/src/models/usersFactory.ts @@ -72,7 +72,7 @@ export default class UsersFactory extends AbstractModelFactory = { email, password: hashedPassword, notifications: UserModel.generateDefaultNotificationsSettings(email), diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 6468d83f..86a9c6ef 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -11,6 +11,7 @@ import { dateFromObjectId } from '../utils/dates'; import { UserDBScheme } from '@hawk.so/types'; import * as telegram from '../utils/telegram'; import { MongoError } from 'mongodb'; +import { validateUtmParams, sanitizeUtmParams } from '../utils/analytics'; /** * See all types and fields here {@see ../typeDefs/user.graphql} @@ -45,10 +46,17 @@ export default { { email, utm }: { email: string; utm?: UserDBScheme['utm'] }, { factories }: ResolverContextBase ): Promise { + // Validate and sanitize UTM parameters + if (!validateUtmParams(utm)) { + throw new UserInputError('Invalid UTM parameters provided'); + } + + const sanitizedUtm = sanitizeUtmParams(utm); + let user; try { - user = await factories.usersFactory.create(email, undefined, utm); + user = await factories.usersFactory.create(email, undefined, sanitizedUtm); const password = user.generatedPassword!; diff --git a/src/utils/analytics/index.ts b/src/utils/analytics/index.ts index 40d26b1a..827cbded 100644 --- a/src/utils/analytics/index.ts +++ b/src/utils/analytics/index.ts @@ -1,2 +1,3 @@ export { Analytics } from './amplitude'; export { AnalyticsEventTypes } from './events'; +export { validateUtmParams, sanitizeUtmParams } from './utm'; diff --git a/src/utils/analytics/utm.ts b/src/utils/analytics/utm.ts new file mode 100644 index 00000000..d114d77a --- /dev/null +++ b/src/utils/analytics/utm.ts @@ -0,0 +1,62 @@ +import { UserDBScheme } from '@hawk.so/types'; + +/** + * Validates UTM parameters + * @param utm - Data form where user went to sign up. Used for analytics purposes + * @returns boolean - true if valid, false if invalid + */ +export function validateUtmParams(utm: UserDBScheme['utm']): boolean { + if (!utm) return true; + + const utmKeys = ['source', 'medium', 'campaign', 'content', 'term']; + const providedKeys = Object.keys(utm); + + // Check if all provided keys are valid UTM keys + const hasInvalidKeys = providedKeys.some((key) => !utmKeys.includes(key)); + if (hasInvalidKeys) { + return false; + } + + // Check if values are strings and not too long + for (const [key, value] of Object.entries(utm)) { + if (value !== undefined && value !== null) { + if (typeof value !== 'string' || value.length > 200) { + return false; + } + // Basic sanitization - only allow alphanumeric, spaces, hyphens, underscores, dots + if (!/^[a-zA-Z0-9\s\-_\.]+$/.test(value)) { + return false; + } + } + } + + return true; +} + +/** + * Sanitizes UTM parameters by removing invalid characters + * @param utm - Data form where user went to sign up. Used for analytics purposes + * @returns sanitized UTM parameters or undefined if invalid + */ +export function sanitizeUtmParams(utm: UserDBScheme['utm']): UserDBScheme['utm'] { + if (!utm) return undefined; + + const utmKeys = ['source', 'medium', 'campaign', 'content', 'term']; + const sanitized: UserDBScheme['utm'] = {}; + + for (const [key, value] of Object.entries(utm)) { + if (utmKeys.includes(key) && value && typeof value === 'string') { + // Sanitize value: keep only allowed characters and limit length + const cleanValue = value + .replace(/[^a-zA-Z0-9\s\-_\.]/g, '') + .trim() + .substring(0, 200); + + if (cleanValue.length > 0) { + (sanitized as any)[key] = cleanValue; + } + } + } + + return Object.keys(sanitized).length > 0 ? sanitized : undefined; +} From 14dc023920b3ea5e8fa9290bce57e1d6afd43a62 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 6 Aug 2025 17:53:29 +0300 Subject: [PATCH 06/21] feat(analytics): enhance UTM parameter validation to include object type checks and sanitization rules --- src/utils/analytics/utm.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/utils/analytics/utm.ts b/src/utils/analytics/utm.ts index d114d77a..95bec057 100644 --- a/src/utils/analytics/utm.ts +++ b/src/utils/analytics/utm.ts @@ -8,9 +8,19 @@ import { UserDBScheme } from '@hawk.so/types'; export function validateUtmParams(utm: UserDBScheme['utm']): boolean { if (!utm) return true; + // Check if utm is an object + if (typeof utm !== 'object' || Array.isArray(utm)) { + return false; + } + const utmKeys = ['source', 'medium', 'campaign', 'content', 'term']; const providedKeys = Object.keys(utm); + // Check if utm object is not empty + if (providedKeys.length === 0) { + return true; // Empty object is valid + } + // Check if all provided keys are valid UTM keys const hasInvalidKeys = providedKeys.some((key) => !utmKeys.includes(key)); if (hasInvalidKeys) { @@ -20,10 +30,16 @@ export function validateUtmParams(utm: UserDBScheme['utm']): boolean { // Check if values are strings and not too long for (const [key, value] of Object.entries(utm)) { if (value !== undefined && value !== null) { - if (typeof value !== 'string' || value.length > 200) { + if (typeof value !== 'string') { return false; } - // Basic sanitization - only allow alphanumeric, spaces, hyphens, underscores, dots + + // Check length + if (value.length === 0 || value.length > 200) { + return false; + } + + // Check for valid characters - only allow alphanumeric, spaces, hyphens, underscores, dots if (!/^[a-zA-Z0-9\s\-_\.]+$/.test(value)) { return false; } From 31ff97d406315917c85f5a8d9d30b700b0a8515e Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 6 Aug 2025 18:10:20 +0300 Subject: [PATCH 07/21] refactor(analytics): improve readability of UTM parameter validation and sanitization functions --- src/utils/analytics/utm.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/utils/analytics/utm.ts b/src/utils/analytics/utm.ts index 95bec057..5d7faeb9 100644 --- a/src/utils/analytics/utm.ts +++ b/src/utils/analytics/utm.ts @@ -6,7 +6,9 @@ import { UserDBScheme } from '@hawk.so/types'; * @returns boolean - true if valid, false if invalid */ export function validateUtmParams(utm: UserDBScheme['utm']): boolean { - if (!utm) return true; + if (!utm) { + return true; + } // Check if utm is an object if (typeof utm !== 'object' || Array.isArray(utm)) { @@ -40,7 +42,7 @@ export function validateUtmParams(utm: UserDBScheme['utm']): boolean { } // Check for valid characters - only allow alphanumeric, spaces, hyphens, underscores, dots - if (!/^[a-zA-Z0-9\s\-_\.]+$/.test(value)) { + if (!/^[a-zA-Z0-9\s\-_.]+$/.test(value)) { return false; } } @@ -55,7 +57,9 @@ export function validateUtmParams(utm: UserDBScheme['utm']): boolean { * @returns sanitized UTM parameters or undefined if invalid */ export function sanitizeUtmParams(utm: UserDBScheme['utm']): UserDBScheme['utm'] { - if (!utm) return undefined; + if (!utm) { + return undefined; + } const utmKeys = ['source', 'medium', 'campaign', 'content', 'term']; const sanitized: UserDBScheme['utm'] = {}; @@ -64,7 +68,7 @@ export function sanitizeUtmParams(utm: UserDBScheme['utm']): UserDBScheme['utm'] if (utmKeys.includes(key) && value && typeof value === 'string') { // Sanitize value: keep only allowed characters and limit length const cleanValue = value - .replace(/[^a-zA-Z0-9\s\-_\.]/g, '') + .replace(/[^a-zA-Z0-9\s\-_.]/g, '') .trim() .substring(0, 200); From e6a179fe61e408780b2c3a95cb1e5963d0c30564 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Sat, 9 Aug 2025 12:59:09 +0300 Subject: [PATCH 08/21] refactor(utm): move UTM parameter validation and sanitization to a dedicated module --- src/models/usersFactory.ts | 7 ++++--- src/resolvers/user.ts | 2 +- src/utils/analytics/index.ts | 1 - src/utils/{analytics => utm}/utm.ts | 0 4 files changed, 5 insertions(+), 5 deletions(-) rename src/utils/{analytics => utm}/utm.ts (100%) diff --git a/src/models/usersFactory.ts b/src/models/usersFactory.ts index 8032bb30..29c8b011 100644 --- a/src/models/usersFactory.ts +++ b/src/models/usersFactory.ts @@ -4,6 +4,7 @@ import { Collection, Db } from 'mongodb'; import DataLoaders from '../dataLoaders'; import { UserDBScheme } from '@hawk.so/types'; import { Analytics, AnalyticsEventTypes } from '../utils/analytics'; +import { sanitizeUtmParams, validateUtmParams } from '../utils/utm/utm'; /** * Users factory to work with User Model @@ -78,9 +79,9 @@ export default class UsersFactory extends AbstractModelFactory 0) { - userData.utm = utm; + if (validateUtmParams(utm)) { + const sanitizedUtm = sanitizeUtmParams(utm); + userData.utm = sanitizedUtm; } const userId = (await this.collection.insertOne(userData)).insertedId; diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 86a9c6ef..79be8477 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -11,7 +11,7 @@ import { dateFromObjectId } from '../utils/dates'; import { UserDBScheme } from '@hawk.so/types'; import * as telegram from '../utils/telegram'; import { MongoError } from 'mongodb'; -import { validateUtmParams, sanitizeUtmParams } from '../utils/analytics'; +import { validateUtmParams, sanitizeUtmParams } from '../utils/utm/utm'; /** * See all types and fields here {@see ../typeDefs/user.graphql} diff --git a/src/utils/analytics/index.ts b/src/utils/analytics/index.ts index 827cbded..40d26b1a 100644 --- a/src/utils/analytics/index.ts +++ b/src/utils/analytics/index.ts @@ -1,3 +1,2 @@ export { Analytics } from './amplitude'; export { AnalyticsEventTypes } from './events'; -export { validateUtmParams, sanitizeUtmParams } from './utm'; diff --git a/src/utils/analytics/utm.ts b/src/utils/utm/utm.ts similarity index 100% rename from src/utils/analytics/utm.ts rename to src/utils/utm/utm.ts From c4aceebe70e4316e0b0f8fcac6a6cc576f2c6fdf Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Sat, 9 Aug 2025 13:24:53 +0300 Subject: [PATCH 09/21] refactor(utm): add tests for UTM validation --- test/utils/utm.test.ts | 174 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 test/utils/utm.test.ts diff --git a/test/utils/utm.test.ts b/test/utils/utm.test.ts new file mode 100644 index 00000000..34addd55 --- /dev/null +++ b/test/utils/utm.test.ts @@ -0,0 +1,174 @@ +import { validateUtmParams, sanitizeUtmParams } from '../../src/utils/utm/utm'; + +describe('UTM Utils', () => { + describe('validateUtmParams', () => { + it('should return true for undefined or null utm', () => { + expect(validateUtmParams(undefined)).toBe(true); + expect(validateUtmParams(null as any)).toBe(true); + }); + + it('should return true for empty object', () => { + expect(validateUtmParams({})).toBe(true); + }); + + it('should return false for non-object types', () => { + expect(validateUtmParams('string' as any)).toBe(false); + expect(validateUtmParams(123 as any)).toBe(false); + expect(validateUtmParams(true as any)).toBe(false); + expect(validateUtmParams([] as any)).toBe(false); + }); + + it('should return false for invalid UTM keys', () => { + expect(validateUtmParams({ invalidKey: 'value' } as any)).toBe(false); + expect(validateUtmParams({ source: 'google', invalidKey: 'value' } as any)).toBe(false); + }); + + it('should return true for valid UTM keys', () => { + expect(validateUtmParams({ source: 'google' })).toBe(true); + expect(validateUtmParams({ medium: 'cpc' })).toBe(true); + expect(validateUtmParams({ campaign: 'spring_2025' })).toBe(true); + expect(validateUtmParams({ content: 'ad_variant_a' })).toBe(true); + expect(validateUtmParams({ term: 'error_tracker' })).toBe(true); + }); + + it('should return true for multiple valid UTM keys', () => { + const validUtm = { + source: 'google', + medium: 'cpc', + campaign: 'spring_2025_launch', + content: 'ad_variant_a', + term: 'error_tracker', + }; + expect(validateUtmParams(validUtm)).toBe(true); + }); + + it('should return false for non-string values', () => { + expect(validateUtmParams({ source: 123 } as any)).toBe(false); + expect(validateUtmParams({ source: true } as any)).toBe(false); + expect(validateUtmParams({ source: {} } as any)).toBe(false); + expect(validateUtmParams({ source: [] } as any)).toBe(false); + }); + + it('should return false for empty string values', () => { + expect(validateUtmParams({ source: '' })).toBe(false); + }); + + it('should return false for values that are too long', () => { + const longValue = 'a'.repeat(201); + expect(validateUtmParams({ source: longValue })).toBe(false); + }); + + it('should return true for values at maximum length', () => { + const maxLengthValue = 'a'.repeat(200); + expect(validateUtmParams({ source: maxLengthValue })).toBe(true); + }); + + it('should return false for values with invalid characters', () => { + expect(validateUtmParams({ source: 'google@example' })).toBe(false); + expect(validateUtmParams({ source: 'google#hash' })).toBe(false); + expect(validateUtmParams({ source: 'google$money' })).toBe(false); + expect(validateUtmParams({ source: 'google%percent' })).toBe(false); + }); + + it('should return true for values with valid characters', () => { + expect(validateUtmParams({ source: 'google-ads' })).toBe(true); + expect(validateUtmParams({ source: 'google_ads' })).toBe(true); + expect(validateUtmParams({ source: 'google.com' })).toBe(true); + expect(validateUtmParams({ source: 'Google Ads 123' })).toBe(true); + }); + + it('should handle undefined and null values in object', () => { + expect(validateUtmParams({ source: 'google', medium: undefined })).toBe(true); + expect(validateUtmParams({ source: 'google', medium: null as any })).toBe(true); + }); + }); + + describe('sanitizeUtmParams', () => { + it('should return undefined for undefined or null utm', () => { + expect(sanitizeUtmParams(undefined)).toBeUndefined(); + expect(sanitizeUtmParams(null as any)).toBeUndefined(); + }); + + it('should return undefined for empty object', () => { + expect(sanitizeUtmParams({})).toBeUndefined(); + }); + + it('should filter out invalid keys', () => { + const input = { source: 'google', invalidKey: 'value' } as any; + const result = sanitizeUtmParams(input); + expect(result).toEqual({ source: 'google' }); + }); + + it('should sanitize values by removing invalid characters', () => { + const input = { source: 'google@#$%ads' }; + const result = sanitizeUtmParams(input); + expect(result).toEqual({ source: 'googleads' }); + }); + + it('should trim whitespace', () => { + const input = { source: ' google ads ' }; + const result = sanitizeUtmParams(input); + expect(result).toEqual({ source: 'google ads' }); + }); + + it('should limit length to 200 characters', () => { + const longValue = 'a'.repeat(250); + const input = { source: longValue }; + const result = sanitizeUtmParams(input); + expect(result?.source).toHaveLength(200); + }); + + it('should preserve valid characters', () => { + const input = { + source: 'google-ads', + medium: 'cpc_campaign', + campaign: 'spring.2025', + content: 'Ad Variant 123', + term: 'error_tracker-tool', + }; + const result = sanitizeUtmParams(input); + expect(result).toEqual(input); + }); + + it('should remove entries with empty values after sanitization', () => { + const input = { source: '@#$%', medium: 'cpc' }; + const result = sanitizeUtmParams(input); + expect(result).toEqual({ medium: 'cpc' }); + }); + + it('should return undefined if all values become empty after sanitization', () => { + const input = { source: '@#$%', medium: '!@#$' }; + const result = sanitizeUtmParams(input); + expect(result).toBeUndefined(); + }); + + it('should handle non-string values by filtering them out', () => { + const input = { source: 'google', medium: 123, campaign: true } as any; + const result = sanitizeUtmParams(input); + expect(result).toEqual({ source: 'google' }); + }); + + it('should handle null and undefined values', () => { + const input = { source: 'google', medium: null as any, campaign: undefined }; + const result = sanitizeUtmParams(input); + expect(result).toEqual({ source: 'google' }); + }); + + it('should handle complex sanitization case', () => { + const input = { + source: ' Google@Ads#Campaign ', + medium: 'cpc$paid%search', + campaign: 'spring_2025-launch.campaign', + content: '!@#$%', + term: 'error tracker tool', + }; + const result = sanitizeUtmParams(input); + expect(result).toEqual({ + source: 'GoogleAdsCampaign', + medium: 'cpcpaidsearch', + campaign: 'spring_2025-launch.campaign', + term: 'error tracker tool', + }); + }); + }); +}); From bd6d64a2b4e938a3b01ee5daffc110f312542645 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:09:02 +0300 Subject: [PATCH 10/21] refactor(utm): extract UTM key validation and character checks into constants for improved readability --- src/utils/utm/utm.ts | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/utils/utm/utm.ts b/src/utils/utm/utm.ts index 5d7faeb9..b28d7f91 100644 --- a/src/utils/utm/utm.ts +++ b/src/utils/utm/utm.ts @@ -1,5 +1,22 @@ import { UserDBScheme } from '@hawk.so/types'; +/** + * Valid UTM parameter keys + */ +const VALID_UTM_KEYS = ['source', 'medium', 'campaign', 'content', 'term']; + +/** + * Regular expression for valid UTM characters + * Allows: alphanumeric, spaces, hyphens, underscores, dots + */ +const VALID_UTM_CHARACTERS = /^[a-zA-Z0-9\s\-_.]+$/; + +/** + * Regular expression for invalid UTM characters (inverse of VALID_UTM_CHARACTERS) + * Used for cleaning/sanitizing values + */ +const INVALID_UTM_CHARACTERS = /[^a-zA-Z0-9\s\-_.]/g; + /** * Validates UTM parameters * @param utm - Data form where user went to sign up. Used for analytics purposes @@ -15,7 +32,6 @@ export function validateUtmParams(utm: UserDBScheme['utm']): boolean { return false; } - const utmKeys = ['source', 'medium', 'campaign', 'content', 'term']; const providedKeys = Object.keys(utm); // Check if utm object is not empty @@ -24,7 +40,7 @@ export function validateUtmParams(utm: UserDBScheme['utm']): boolean { } // Check if all provided keys are valid UTM keys - const hasInvalidKeys = providedKeys.some((key) => !utmKeys.includes(key)); + const hasInvalidKeys = providedKeys.some((key) => !VALID_UTM_KEYS.includes(key)); if (hasInvalidKeys) { return false; } @@ -42,7 +58,7 @@ export function validateUtmParams(utm: UserDBScheme['utm']): boolean { } // Check for valid characters - only allow alphanumeric, spaces, hyphens, underscores, dots - if (!/^[a-zA-Z0-9\s\-_.]+$/.test(value)) { + if (!VALID_UTM_CHARACTERS.test(value)) { return false; } } @@ -61,16 +77,12 @@ export function sanitizeUtmParams(utm: UserDBScheme['utm']): UserDBScheme['utm'] return undefined; } - const utmKeys = ['source', 'medium', 'campaign', 'content', 'term']; const sanitized: UserDBScheme['utm'] = {}; for (const [key, value] of Object.entries(utm)) { - if (utmKeys.includes(key) && value && typeof value === 'string') { + if (VALID_UTM_KEYS.includes(key) && value && typeof value === 'string') { // Sanitize value: keep only allowed characters and limit length - const cleanValue = value - .replace(/[^a-zA-Z0-9\s\-_.]/g, '') - .trim() - .substring(0, 200); + const cleanValue = value.replace(INVALID_UTM_CHARACTERS, '').trim().substring(0, 200); if (cleanValue.length > 0) { (sanitized as any)[key] = cleanValue; From 02376cabe93c685041827e8083fa95ac0f2e0439 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:25:05 +0300 Subject: [PATCH 11/21] refactor(utm): define maximum UTM value length as a constant for better maintainability --- src/utils/utm/utm.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/utils/utm/utm.ts b/src/utils/utm/utm.ts index b28d7f91..870b6ae0 100644 --- a/src/utils/utm/utm.ts +++ b/src/utils/utm/utm.ts @@ -17,6 +17,11 @@ const VALID_UTM_CHARACTERS = /^[a-zA-Z0-9\s\-_.]+$/; */ const INVALID_UTM_CHARACTERS = /[^a-zA-Z0-9\s\-_.]/g; +/** + * Maximum allowed length for UTM parameter values + */ +const MAX_UTM_VALUE_LENGTH = 200; + /** * Validates UTM parameters * @param utm - Data form where user went to sign up. Used for analytics purposes @@ -53,7 +58,7 @@ export function validateUtmParams(utm: UserDBScheme['utm']): boolean { } // Check length - if (value.length === 0 || value.length > 200) { + if (value.length === 0 || value.length > MAX_UTM_VALUE_LENGTH) { return false; } @@ -82,7 +87,10 @@ export function sanitizeUtmParams(utm: UserDBScheme['utm']): UserDBScheme['utm'] for (const [key, value] of Object.entries(utm)) { if (VALID_UTM_KEYS.includes(key) && value && typeof value === 'string') { // Sanitize value: keep only allowed characters and limit length - const cleanValue = value.replace(INVALID_UTM_CHARACTERS, '').trim().substring(0, 200); + const cleanValue = value + .replace(INVALID_UTM_CHARACTERS, '') + .trim() + .substring(0, MAX_UTM_VALUE_LENGTH); if (cleanValue.length > 0) { (sanitized as any)[key] = cleanValue; From e8114da90de22b451ee7c4f0b855d1d8f3fc196f Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:12:18 +0300 Subject: [PATCH 12/21] refactor(utm): enhance UTM parameter validation to return detailed results and improve sanitization logic --- src/models/usersFactory.ts | 6 +- src/resolvers/user.ts | 31 +++++++- src/utils/utm/utm.ts | 71 ++++++++++++------ test/utils/utm.test.ts | 148 ++++++++++++++++++++++++++----------- 4 files changed, 182 insertions(+), 74 deletions(-) diff --git a/src/models/usersFactory.ts b/src/models/usersFactory.ts index 29c8b011..f3c6ff70 100644 --- a/src/models/usersFactory.ts +++ b/src/models/usersFactory.ts @@ -4,7 +4,6 @@ import { Collection, Db } from 'mongodb'; import DataLoaders from '../dataLoaders'; import { UserDBScheme } from '@hawk.so/types'; import { Analytics, AnalyticsEventTypes } from '../utils/analytics'; -import { sanitizeUtmParams, validateUtmParams } from '../utils/utm/utm'; /** * Users factory to work with User Model @@ -79,9 +78,8 @@ export default class UsersFactory extends AbstractModelFactory 0) { + userData.utm = utm; } const userId = (await this.collection.insertOne(userData)).insertedId; diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 79be8477..79bfbe1a 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -12,6 +12,7 @@ import { UserDBScheme } from '@hawk.so/types'; import * as telegram from '../utils/telegram'; import { MongoError } from 'mongodb'; import { validateUtmParams, sanitizeUtmParams } from '../utils/utm/utm'; +import HawkCatcher from '@hawk.so/nodejs'; /** * See all types and fields here {@see ../typeDefs/user.graphql} @@ -47,12 +48,34 @@ export default { { factories }: ResolverContextBase ): Promise { // Validate and sanitize UTM parameters - if (!validateUtmParams(utm)) { - throw new UserInputError('Invalid UTM parameters provided'); + let sanitizedUtm; + if (utm) { + const validationResult = validateUtmParams(utm); + + if (validationResult.isValid) { + // All UTM parameters are valid + sanitizedUtm = sanitizeUtmParams(utm, validationResult); + } else if (validationResult.validKeys.length > 0) { + // Some UTM parameters are valid, save only those + sanitizedUtm = sanitizeUtmParams(utm, validationResult); + + // Log the invalid keys for monitoring + HawkCatcher.send(new Error('Some UTM parameters are invalid'), { + email, + utm, + invalidKeys: JSON.stringify(validationResult.invalidKeys), + validKeys: JSON.stringify(validationResult.validKeys), + }); + } else { + // No valid UTM parameters + HawkCatcher.send(new Error('All UTM parameters are invalid'), { + email, + utm, + invalidKeys: JSON.stringify(validationResult.invalidKeys), + }); + } } - const sanitizedUtm = sanitizeUtmParams(utm); - let user; try { diff --git a/src/utils/utm/utm.ts b/src/utils/utm/utm.ts index 870b6ae0..1a55349b 100644 --- a/src/utils/utm/utm.ts +++ b/src/utils/utm/utm.ts @@ -23,69 +23,96 @@ const INVALID_UTM_CHARACTERS = /[^a-zA-Z0-9\s\-_.]/g; const MAX_UTM_VALUE_LENGTH = 200; /** - * Validates UTM parameters + * Validates UTM parameters per key * @param utm - Data form where user went to sign up. Used for analytics purposes - * @returns boolean - true if valid, false if invalid + * @returns object with validation results per key and overall validity */ -export function validateUtmParams(utm: UserDBScheme['utm']): boolean { +export function validateUtmParams(utm: UserDBScheme['utm']): { + isValid: boolean; + validKeys: string[]; + invalidKeys: string[]; +} { if (!utm) { - return true; + return { isValid: true, validKeys: [], invalidKeys: [] }; } // Check if utm is an object if (typeof utm !== 'object' || Array.isArray(utm)) { - return false; + return { isValid: false, validKeys: [], invalidKeys: ['_structure'] }; } const providedKeys = Object.keys(utm); // Check if utm object is not empty if (providedKeys.length === 0) { - return true; // Empty object is valid + return { isValid: true, validKeys: [], invalidKeys: [] }; } - // Check if all provided keys are valid UTM keys - const hasInvalidKeys = providedKeys.some((key) => !VALID_UTM_KEYS.includes(key)); - if (hasInvalidKeys) { - return false; - } + const validKeys: string[] = []; + const invalidKeys: string[] = []; - // Check if values are strings and not too long for (const [key, value] of Object.entries(utm)) { + // Check if key is valid UTM key + if (!VALID_UTM_KEYS.includes(key)) { + invalidKeys.push(key); + continue; + } + + // Check if value is valid if (value !== undefined && value !== null) { if (typeof value !== 'string') { - return false; + invalidKeys.push(key); + continue; } // Check length if (value.length === 0 || value.length > MAX_UTM_VALUE_LENGTH) { - return false; + invalidKeys.push(key); + continue; } - // Check for valid characters - only allow alphanumeric, spaces, hyphens, underscores, dots + // Check for valid characters if (!VALID_UTM_CHARACTERS.test(value)) { - return false; + invalidKeys.push(key); + continue; } } + + validKeys.push(key); } - return true; + return { + isValid: invalidKeys.length === 0, + validKeys, + invalidKeys, + }; } /** - * Sanitizes UTM parameters by removing invalid characters + * Sanitizes UTM parameters by keeping only valid keys and cleaning values * @param utm - Data form where user went to sign up. Used for analytics purposes - * @returns sanitized UTM parameters or undefined if invalid + * @param validationResult - Optional validation result to use valid keys from + * @returns sanitized UTM parameters or undefined if no valid data */ -export function sanitizeUtmParams(utm: UserDBScheme['utm']): UserDBScheme['utm'] { +export function sanitizeUtmParams( + utm: UserDBScheme['utm'], + validationResult?: { validKeys: string[]; invalidKeys: string[] } +): UserDBScheme['utm'] { if (!utm) { return undefined; } const sanitized: UserDBScheme['utm'] = {}; - for (const [key, value] of Object.entries(utm)) { - if (VALID_UTM_KEYS.includes(key) && value && typeof value === 'string') { + // Use validation result if provided, otherwise validate inline + const keysToProcess = validationResult + ? validationResult.validKeys + : Object.keys(utm).filter((key) => VALID_UTM_KEYS.includes(key)); + + for (const key of keysToProcess) { + const value = (utm as any)[key]; + + if (value && typeof value === 'string') { // Sanitize value: keep only allowed characters and limit length const cleanValue = value .replace(INVALID_UTM_CHARACTERS, '') diff --git a/test/utils/utm.test.ts b/test/utils/utm.test.ts index 34addd55..7c0d262f 100644 --- a/test/utils/utm.test.ts +++ b/test/utils/utm.test.ts @@ -2,36 +2,70 @@ import { validateUtmParams, sanitizeUtmParams } from '../../src/utils/utm/utm'; describe('UTM Utils', () => { describe('validateUtmParams', () => { - it('should return true for undefined or null utm', () => { - expect(validateUtmParams(undefined)).toBe(true); - expect(validateUtmParams(null as any)).toBe(true); + it('should return valid result for undefined or null utm', () => { + expect(validateUtmParams(undefined)).toEqual({ + isValid: true, + validKeys: [], + invalidKeys: [], + }); + expect(validateUtmParams(null as any)).toEqual({ + isValid: true, + validKeys: [], + invalidKeys: [], + }); }); - it('should return true for empty object', () => { - expect(validateUtmParams({})).toBe(true); + it('should return valid result for empty object', () => { + expect(validateUtmParams({})).toEqual({ isValid: true, validKeys: [], invalidKeys: [] }); }); - it('should return false for non-object types', () => { - expect(validateUtmParams('string' as any)).toBe(false); - expect(validateUtmParams(123 as any)).toBe(false); - expect(validateUtmParams(true as any)).toBe(false); - expect(validateUtmParams([] as any)).toBe(false); + it('should return invalid result for non-object types', () => { + expect(validateUtmParams('string' as any)).toEqual({ + isValid: false, + validKeys: [], + invalidKeys: ['_structure'], + }); + expect(validateUtmParams(123 as any)).toEqual({ + isValid: false, + validKeys: [], + invalidKeys: ['_structure'], + }); + expect(validateUtmParams(true as any)).toEqual({ + isValid: false, + validKeys: [], + invalidKeys: ['_structure'], + }); + expect(validateUtmParams([] as any)).toEqual({ + isValid: false, + validKeys: [], + invalidKeys: ['_structure'], + }); }); - it('should return false for invalid UTM keys', () => { - expect(validateUtmParams({ invalidKey: 'value' } as any)).toBe(false); - expect(validateUtmParams({ source: 'google', invalidKey: 'value' } as any)).toBe(false); + it('should identify invalid UTM keys', () => { + const result1 = validateUtmParams({ invalidKey: 'value' } as any); + expect(result1.isValid).toBe(false); + expect(result1.invalidKeys).toContain('invalidKey'); + expect(result1.validKeys).toEqual([]); + + const result2 = validateUtmParams({ source: 'google', invalidKey: 'value' } as any); + expect(result2.isValid).toBe(false); + expect(result2.invalidKeys).toContain('invalidKey'); + expect(result2.validKeys).toContain('source'); }); - it('should return true for valid UTM keys', () => { - expect(validateUtmParams({ source: 'google' })).toBe(true); - expect(validateUtmParams({ medium: 'cpc' })).toBe(true); - expect(validateUtmParams({ campaign: 'spring_2025' })).toBe(true); - expect(validateUtmParams({ content: 'ad_variant_a' })).toBe(true); - expect(validateUtmParams({ term: 'error_tracker' })).toBe(true); + it('should return valid result for valid UTM keys', () => { + const result1 = validateUtmParams({ source: 'google' }); + expect(result1.isValid).toBe(true); + expect(result1.validKeys).toContain('source'); + expect(result1.invalidKeys).toEqual([]); + + const result2 = validateUtmParams({ medium: 'cpc' }); + expect(result2.isValid).toBe(true); + expect(result2.validKeys).toContain('medium'); }); - it('should return true for multiple valid UTM keys', () => { + it('should validate multiple UTM keys correctly', () => { const validUtm = { source: 'google', medium: 'cpc', @@ -39,47 +73,73 @@ describe('UTM Utils', () => { content: 'ad_variant_a', term: 'error_tracker', }; - expect(validateUtmParams(validUtm)).toBe(true); + const result = validateUtmParams(validUtm); + expect(result.isValid).toBe(true); + expect(result.validKeys).toEqual(['source', 'medium', 'campaign', 'content', 'term']); + expect(result.invalidKeys).toEqual([]); }); - it('should return false for non-string values', () => { - expect(validateUtmParams({ source: 123 } as any)).toBe(false); - expect(validateUtmParams({ source: true } as any)).toBe(false); - expect(validateUtmParams({ source: {} } as any)).toBe(false); - expect(validateUtmParams({ source: [] } as any)).toBe(false); + it('should identify non-string values as invalid', () => { + const result1 = validateUtmParams({ source: 123 } as any); + expect(result1.isValid).toBe(false); + expect(result1.invalidKeys).toContain('source'); + + const result2 = validateUtmParams({ source: 'google', medium: true } as any); + expect(result2.isValid).toBe(false); + expect(result2.validKeys).toContain('source'); + expect(result2.invalidKeys).toContain('medium'); }); - it('should return false for empty string values', () => { - expect(validateUtmParams({ source: '' })).toBe(false); + it('should identify empty string values as invalid', () => { + const result = validateUtmParams({ source: '' }); + expect(result.isValid).toBe(false); + expect(result.invalidKeys).toContain('source'); }); - it('should return false for values that are too long', () => { + it('should identify values that are too long as invalid', () => { const longValue = 'a'.repeat(201); - expect(validateUtmParams({ source: longValue })).toBe(false); + const result = validateUtmParams({ source: longValue }); + expect(result.isValid).toBe(false); + expect(result.invalidKeys).toContain('source'); }); - it('should return true for values at maximum length', () => { + it('should accept values at maximum length', () => { const maxLengthValue = 'a'.repeat(200); - expect(validateUtmParams({ source: maxLengthValue })).toBe(true); + const result = validateUtmParams({ source: maxLengthValue }); + expect(result.isValid).toBe(true); + expect(result.validKeys).toContain('source'); + }); + + it('should identify values with invalid characters', () => { + const result = validateUtmParams({ source: 'google@example' }); + expect(result.isValid).toBe(false); + expect(result.invalidKeys).toContain('source'); }); - it('should return false for values with invalid characters', () => { - expect(validateUtmParams({ source: 'google@example' })).toBe(false); - expect(validateUtmParams({ source: 'google#hash' })).toBe(false); - expect(validateUtmParams({ source: 'google$money' })).toBe(false); - expect(validateUtmParams({ source: 'google%percent' })).toBe(false); + it('should accept values with valid characters', () => { + const result = validateUtmParams({ source: 'google-ads' }); + expect(result.isValid).toBe(true); + expect(result.validKeys).toContain('source'); }); - it('should return true for values with valid characters', () => { - expect(validateUtmParams({ source: 'google-ads' })).toBe(true); - expect(validateUtmParams({ source: 'google_ads' })).toBe(true); - expect(validateUtmParams({ source: 'google.com' })).toBe(true); - expect(validateUtmParams({ source: 'Google Ads 123' })).toBe(true); + it('should handle mixed valid and invalid keys', () => { + const input = { + source: 'google', + medium: 'invalid@chars', + campaign: 'valid_campaign', + invalidKey: 'value', + } as any; + const result = validateUtmParams(input); + expect(result.isValid).toBe(false); + expect(result.validKeys).toEqual(['source', 'campaign']); + expect(result.invalidKeys).toEqual(['medium', 'invalidKey']); }); it('should handle undefined and null values in object', () => { - expect(validateUtmParams({ source: 'google', medium: undefined })).toBe(true); - expect(validateUtmParams({ source: 'google', medium: null as any })).toBe(true); + const result = validateUtmParams({ source: 'google', medium: undefined }); + expect(result.isValid).toBe(true); + expect(result.validKeys).toContain('source'); + expect(result.validKeys).toContain('medium'); }); }); From 64b7122f7f6b5c36c7816a80601bca54a27094e8 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:16:20 +0300 Subject: [PATCH 13/21] chore(package): bump version to 1.1.32 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f31f1b1e..d3fc3c37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.1.30", + "version": "1.1.32", "main": "index.ts", "license": "UNLICENSED", "scripts": { From c9e37d73420bef70d2c43650fdb27ffa6686e720 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:22:51 +0300 Subject: [PATCH 14/21] fix lint --- src/utils/utm/utm.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/utils/utm/utm.ts b/src/utils/utm/utm.ts index 1a55349b..f655d45d 100644 --- a/src/utils/utm/utm.ts +++ b/src/utils/utm/utm.ts @@ -33,19 +33,31 @@ export function validateUtmParams(utm: UserDBScheme['utm']): { invalidKeys: string[]; } { if (!utm) { - return { isValid: true, validKeys: [], invalidKeys: [] }; + return { + isValid: true, + validKeys: [], + invalidKeys: [], + }; } // Check if utm is an object if (typeof utm !== 'object' || Array.isArray(utm)) { - return { isValid: false, validKeys: [], invalidKeys: ['_structure'] }; + return { + isValid: false, + validKeys: [], + invalidKeys: ['_structure'], + }; } const providedKeys = Object.keys(utm); // Check if utm object is not empty if (providedKeys.length === 0) { - return { isValid: true, validKeys: [], invalidKeys: [] }; + return { + isValid: true, + validKeys: [], + invalidKeys: [], + }; } const validKeys: string[] = []; From 4c6496e6eef9b750bc1f0caa07926d2807291ebd Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:26:06 +0300 Subject: [PATCH 15/21] fix lint --- src/utils/utm/utm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/utm/utm.ts b/src/utils/utm/utm.ts index f655d45d..e1dfa635 100644 --- a/src/utils/utm/utm.ts +++ b/src/utils/utm/utm.ts @@ -45,7 +45,7 @@ export function validateUtmParams(utm: UserDBScheme['utm']): { return { isValid: false, validKeys: [], - invalidKeys: ['_structure'], + invalidKeys: [ '_structure' ], }; } From 7febc318854116c2add808b6d82211dda3360003 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 13 Aug 2025 18:17:11 +0300 Subject: [PATCH 16/21] refactor(utm): streamline UTM parameter sanitization and improve error logging for invalid keys --- src/resolvers/user.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 79bfbe1a..47ab5a61 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -52,27 +52,24 @@ export default { if (utm) { const validationResult = validateUtmParams(utm); - if (validationResult.isValid) { - // All UTM parameters are valid - sanitizedUtm = sanitizeUtmParams(utm, validationResult); - } else if (validationResult.validKeys.length > 0) { - // Some UTM parameters are valid, save only those + // Always try to sanitize valid keys (if any) + if (validationResult.validKeys.length > 0) { sanitizedUtm = sanitizeUtmParams(utm, validationResult); + } - // Log the invalid keys for monitoring - HawkCatcher.send(new Error('Some UTM parameters are invalid'), { + // Log invalid keys for monitoring (if any) + if (validationResult.invalidKeys.length > 0) { + const errorMessage = + validationResult.validKeys.length > 0 + ? 'Some UTM parameters are invalid' + : 'All UTM parameters are invalid'; + + HawkCatcher.send(new Error(errorMessage), { email, utm, invalidKeys: JSON.stringify(validationResult.invalidKeys), validKeys: JSON.stringify(validationResult.validKeys), }); - } else { - // No valid UTM parameters - HawkCatcher.send(new Error('All UTM parameters are invalid'), { - email, - utm, - invalidKeys: JSON.stringify(validationResult.invalidKeys), - }); } } From 5d3ee35f98f7b849c97e7e9c9bc3649e3c9e29ff Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 13 Aug 2025 18:21:38 +0300 Subject: [PATCH 17/21] test(utm): update UTM parameter validation tests to check for valid and invalid keys --- test/utils/utm.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/utils/utm.test.ts b/test/utils/utm.test.ts index 7c0d262f..58aa48df 100644 --- a/test/utils/utm.test.ts +++ b/test/utils/utm.test.ts @@ -139,7 +139,8 @@ describe('UTM Utils', () => { const result = validateUtmParams({ source: 'google', medium: undefined }); expect(result.isValid).toBe(true); expect(result.validKeys).toContain('source'); - expect(result.validKeys).toContain('medium'); + expect(result.validKeys).toEqual(['source', 'medium']); // undefined values are treated as valid (skipped) + expect(result.invalidKeys).toEqual([]); }); }); From e67f815c1432d8b15e4e2859a451824ccd2bc206 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 13 Aug 2025 19:50:14 +0300 Subject: [PATCH 18/21] refactor(utm): simplify UTM parameter handling by removing sanitization and directly validating parameters --- src/models/usersFactory.ts | 5 +- src/resolvers/user.ts | 30 +---- src/utils/utm/utm.ts | 129 ++++----------------- test/utils/utm.test.ts | 232 +++++++++++-------------------------- 4 files changed, 92 insertions(+), 304 deletions(-) diff --git a/src/models/usersFactory.ts b/src/models/usersFactory.ts index f3c6ff70..adfa0852 100644 --- a/src/models/usersFactory.ts +++ b/src/models/usersFactory.ts @@ -76,12 +76,9 @@ export default class UsersFactory extends AbstractModelFactory 0) { - userData.utm = utm; - } - const userId = (await this.collection.insertOne(userData)).insertedId; const user = new UserModel({ diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 47ab5a61..8b9911f2 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -11,8 +11,7 @@ import { dateFromObjectId } from '../utils/dates'; import { UserDBScheme } from '@hawk.so/types'; import * as telegram from '../utils/telegram'; import { MongoError } from 'mongodb'; -import { validateUtmParams, sanitizeUtmParams } from '../utils/utm/utm'; -import HawkCatcher from '@hawk.so/nodejs'; +import { validateUtmParams } from '../utils/utm/utm'; /** * See all types and fields here {@see ../typeDefs/user.graphql} @@ -47,36 +46,13 @@ export default { { email, utm }: { email: string; utm?: UserDBScheme['utm'] }, { factories }: ResolverContextBase ): Promise { - // Validate and sanitize UTM parameters - let sanitizedUtm; - if (utm) { - const validationResult = validateUtmParams(utm); - - // Always try to sanitize valid keys (if any) - if (validationResult.validKeys.length > 0) { - sanitizedUtm = sanitizeUtmParams(utm, validationResult); - } - // Log invalid keys for monitoring (if any) - if (validationResult.invalidKeys.length > 0) { - const errorMessage = - validationResult.validKeys.length > 0 - ? 'Some UTM parameters are invalid' - : 'All UTM parameters are invalid'; - - HawkCatcher.send(new Error(errorMessage), { - email, - utm, - invalidKeys: JSON.stringify(validationResult.invalidKeys), - validKeys: JSON.stringify(validationResult.validKeys), - }); - } - } + const validatedUtm = validateUtmParams(utm); let user; try { - user = await factories.usersFactory.create(email, undefined, sanitizedUtm); + user = await factories.usersFactory.create(email, undefined, validatedUtm); const password = user.generatedPassword!; diff --git a/src/utils/utm/utm.ts b/src/utils/utm/utm.ts index e1dfa635..a2b47d09 100644 --- a/src/utils/utm/utm.ts +++ b/src/utils/utm/utm.ts @@ -1,5 +1,3 @@ -import { UserDBScheme } from '@hawk.so/types'; - /** * Valid UTM parameter keys */ @@ -11,131 +9,44 @@ const VALID_UTM_KEYS = ['source', 'medium', 'campaign', 'content', 'term']; */ const VALID_UTM_CHARACTERS = /^[a-zA-Z0-9\s\-_.]+$/; -/** - * Regular expression for invalid UTM characters (inverse of VALID_UTM_CHARACTERS) - * Used for cleaning/sanitizing values - */ -const INVALID_UTM_CHARACTERS = /[^a-zA-Z0-9\s\-_.]/g; - /** * Maximum allowed length for UTM parameter values */ -const MAX_UTM_VALUE_LENGTH = 200; +const MAX_UTM_VALUE_LENGTH = 50; /** - * Validates UTM parameters per key - * @param utm - Data form where user went to sign up. Used for analytics purposes - * @returns object with validation results per key and overall validity + * Validates and filters UTM parameters + * @param {Object} utm - UTM parameters to validate + * @returns {Object} - filtered valid UTM parameters */ -export function validateUtmParams(utm: UserDBScheme['utm']): { - isValid: boolean; - validKeys: string[]; - invalidKeys: string[]; -} { - if (!utm) { - return { - isValid: true, - validKeys: [], - invalidKeys: [], - }; - } - - // Check if utm is an object - if (typeof utm !== 'object' || Array.isArray(utm)) { - return { - isValid: false, - validKeys: [], - invalidKeys: [ '_structure' ], - }; - } - - const providedKeys = Object.keys(utm); - - // Check if utm object is not empty - if (providedKeys.length === 0) { - return { - isValid: true, - validKeys: [], - invalidKeys: [], - }; +export function validateUtmParams(utm: any): Record { + if (!utm || typeof utm !== 'object' || Array.isArray(utm)) { + return {}; } - const validKeys: string[] = []; - const invalidKeys: string[] = []; + const result: Record = {}; for (const [key, value] of Object.entries(utm)) { - // Check if key is valid UTM key + // 1) Remove keys that are not VALID_UTM_KEYS if (!VALID_UTM_KEYS.includes(key)) { - invalidKeys.push(key); continue; } - // Check if value is valid - if (value !== undefined && value !== null) { - if (typeof value !== 'string') { - invalidKeys.push(key); - continue; - } - - // Check length - if (value.length === 0 || value.length > MAX_UTM_VALUE_LENGTH) { - invalidKeys.push(key); - continue; - } - - // Check for valid characters - if (!VALID_UTM_CHARACTERS.test(value)) { - invalidKeys.push(key); - continue; - } + // 2) Check each condition separately + if (!value || typeof value !== 'string') { + continue; } - validKeys.push(key); - } - - return { - isValid: invalidKeys.length === 0, - validKeys, - invalidKeys, - }; -} - -/** - * Sanitizes UTM parameters by keeping only valid keys and cleaning values - * @param utm - Data form where user went to sign up. Used for analytics purposes - * @param validationResult - Optional validation result to use valid keys from - * @returns sanitized UTM parameters or undefined if no valid data - */ -export function sanitizeUtmParams( - utm: UserDBScheme['utm'], - validationResult?: { validKeys: string[]; invalidKeys: string[] } -): UserDBScheme['utm'] { - if (!utm) { - return undefined; - } - - const sanitized: UserDBScheme['utm'] = {}; - - // Use validation result if provided, otherwise validate inline - const keysToProcess = validationResult - ? validationResult.validKeys - : Object.keys(utm).filter((key) => VALID_UTM_KEYS.includes(key)); - - for (const key of keysToProcess) { - const value = (utm as any)[key]; - - if (value && typeof value === 'string') { - // Sanitize value: keep only allowed characters and limit length - const cleanValue = value - .replace(INVALID_UTM_CHARACTERS, '') - .trim() - .substring(0, MAX_UTM_VALUE_LENGTH); + if (value.length === 0 || value.length > MAX_UTM_VALUE_LENGTH) { + continue; + } - if (cleanValue.length > 0) { - (sanitized as any)[key] = cleanValue; - } + if (!VALID_UTM_CHARACTERS.test(value)) { + continue; } + + result[key] = value; } - return Object.keys(sanitized).length > 0 ? sanitized : undefined; + return result; } diff --git a/test/utils/utm.test.ts b/test/utils/utm.test.ts index 58aa48df..90b452ea 100644 --- a/test/utils/utm.test.ts +++ b/test/utils/utm.test.ts @@ -1,68 +1,37 @@ -import { validateUtmParams, sanitizeUtmParams } from '../../src/utils/utm/utm'; +import { validateUtmParams } from '../../src/utils/utm/utm'; describe('UTM Utils', () => { describe('validateUtmParams', () => { - it('should return valid result for undefined or null utm', () => { - expect(validateUtmParams(undefined)).toEqual({ - isValid: true, - validKeys: [], - invalidKeys: [], - }); - expect(validateUtmParams(null as any)).toEqual({ - isValid: true, - validKeys: [], - invalidKeys: [], - }); + it('should return empty object for undefined or null utm', () => { + expect(validateUtmParams(undefined)).toEqual({}); + expect(validateUtmParams(null as any)).toEqual({}); }); - it('should return valid result for empty object', () => { - expect(validateUtmParams({})).toEqual({ isValid: true, validKeys: [], invalidKeys: [] }); + it('should return empty object for empty object', () => { + expect(validateUtmParams({})).toEqual({}); }); - it('should return invalid result for non-object types', () => { - expect(validateUtmParams('string' as any)).toEqual({ - isValid: false, - validKeys: [], - invalidKeys: ['_structure'], - }); - expect(validateUtmParams(123 as any)).toEqual({ - isValid: false, - validKeys: [], - invalidKeys: ['_structure'], - }); - expect(validateUtmParams(true as any)).toEqual({ - isValid: false, - validKeys: [], - invalidKeys: ['_structure'], - }); - expect(validateUtmParams([] as any)).toEqual({ - isValid: false, - validKeys: [], - invalidKeys: ['_structure'], - }); + it('should return empty object for non-object types', () => { + expect(validateUtmParams('string' as any)).toEqual({}); + expect(validateUtmParams(123 as any)).toEqual({}); + expect(validateUtmParams(true as any)).toEqual({}); + expect(validateUtmParams([] as any)).toEqual({}); }); - it('should identify invalid UTM keys', () => { + it('should filter out invalid UTM keys', () => { const result1 = validateUtmParams({ invalidKey: 'value' } as any); - expect(result1.isValid).toBe(false); - expect(result1.invalidKeys).toContain('invalidKey'); - expect(result1.validKeys).toEqual([]); + expect(result1).toEqual({}); const result2 = validateUtmParams({ source: 'google', invalidKey: 'value' } as any); - expect(result2.isValid).toBe(false); - expect(result2.invalidKeys).toContain('invalidKey'); - expect(result2.validKeys).toContain('source'); + expect(result2).toEqual({ source: 'google' }); }); - it('should return valid result for valid UTM keys', () => { + it('should return valid UTM parameters', () => { const result1 = validateUtmParams({ source: 'google' }); - expect(result1.isValid).toBe(true); - expect(result1.validKeys).toContain('source'); - expect(result1.invalidKeys).toEqual([]); + expect(result1).toEqual({ source: 'google' }); const result2 = validateUtmParams({ medium: 'cpc' }); - expect(result2.isValid).toBe(true); - expect(result2.validKeys).toContain('medium'); + expect(result2).toEqual({ medium: 'cpc' }); }); it('should validate multiple UTM keys correctly', () => { @@ -74,52 +43,60 @@ describe('UTM Utils', () => { term: 'error_tracker', }; const result = validateUtmParams(validUtm); - expect(result.isValid).toBe(true); - expect(result.validKeys).toEqual(['source', 'medium', 'campaign', 'content', 'term']); - expect(result.invalidKeys).toEqual([]); + expect(result).toEqual(validUtm); }); - it('should identify non-string values as invalid', () => { + it('should filter out non-string values', () => { const result1 = validateUtmParams({ source: 123 } as any); - expect(result1.isValid).toBe(false); - expect(result1.invalidKeys).toContain('source'); + expect(result1).toEqual({}); const result2 = validateUtmParams({ source: 'google', medium: true } as any); - expect(result2.isValid).toBe(false); - expect(result2.validKeys).toContain('source'); - expect(result2.invalidKeys).toContain('medium'); + expect(result2).toEqual({ source: 'google' }); }); - it('should identify empty string values as invalid', () => { + it('should filter out empty string values', () => { const result = validateUtmParams({ source: '' }); - expect(result.isValid).toBe(false); - expect(result.invalidKeys).toContain('source'); + expect(result).toEqual({}); }); - it('should identify values that are too long as invalid', () => { - const longValue = 'a'.repeat(201); + it('should filter out values that are too long', () => { + const longValue = 'a'.repeat(51); const result = validateUtmParams({ source: longValue }); - expect(result.isValid).toBe(false); - expect(result.invalidKeys).toContain('source'); + expect(result).toEqual({}); }); it('should accept values at maximum length', () => { - const maxLengthValue = 'a'.repeat(200); + const maxLengthValue = 'a'.repeat(50); const result = validateUtmParams({ source: maxLengthValue }); - expect(result.isValid).toBe(true); - expect(result.validKeys).toContain('source'); + expect(result).toEqual({ source: maxLengthValue }); }); - it('should identify values with invalid characters', () => { - const result = validateUtmParams({ source: 'google@example' }); - expect(result.isValid).toBe(false); - expect(result.invalidKeys).toContain('source'); + it('should filter out values with invalid characters', () => { + const result1 = validateUtmParams({ source: 'google@example' }); + expect(result1).toEqual({}); + + const result2 = validateUtmParams({ source: 'google######' }); + expect(result2).toEqual({}); }); it('should accept values with valid characters', () => { const result = validateUtmParams({ source: 'google-ads' }); - expect(result.isValid).toBe(true); - expect(result.validKeys).toContain('source'); + expect(result).toEqual({ source: 'google-ads' }); + + const result2 = validateUtmParams({ + source: 'google_ads', + medium: 'cpc-campaign', + campaign: 'spring.2025', + content: 'Ad Variant 123', + term: 'error tracker', + }); + expect(result2).toEqual({ + source: 'google_ads', + medium: 'cpc-campaign', + campaign: 'spring.2025', + content: 'Ad Variant 123', + term: 'error tracker', + }); }); it('should handle mixed valid and invalid keys', () => { @@ -130,105 +107,32 @@ describe('UTM Utils', () => { invalidKey: 'value', } as any; const result = validateUtmParams(input); - expect(result.isValid).toBe(false); - expect(result.validKeys).toEqual(['source', 'campaign']); - expect(result.invalidKeys).toEqual(['medium', 'invalidKey']); - }); - - it('should handle undefined and null values in object', () => { - const result = validateUtmParams({ source: 'google', medium: undefined }); - expect(result.isValid).toBe(true); - expect(result.validKeys).toContain('source'); - expect(result.validKeys).toEqual(['source', 'medium']); // undefined values are treated as valid (skipped) - expect(result.invalidKeys).toEqual([]); - }); - }); - - describe('sanitizeUtmParams', () => { - it('should return undefined for undefined or null utm', () => { - expect(sanitizeUtmParams(undefined)).toBeUndefined(); - expect(sanitizeUtmParams(null as any)).toBeUndefined(); + expect(result).toEqual({ source: 'google', campaign: 'valid_campaign' }); }); - it('should return undefined for empty object', () => { - expect(sanitizeUtmParams({})).toBeUndefined(); - }); - - it('should filter out invalid keys', () => { - const input = { source: 'google', invalidKey: 'value' } as any; - const result = sanitizeUtmParams(input); - expect(result).toEqual({ source: 'google' }); - }); - - it('should sanitize values by removing invalid characters', () => { - const input = { source: 'google@#$%ads' }; - const result = sanitizeUtmParams(input); - expect(result).toEqual({ source: 'googleads' }); - }); - - it('should trim whitespace', () => { - const input = { source: ' google ads ' }; - const result = sanitizeUtmParams(input); - expect(result).toEqual({ source: 'google ads' }); - }); - - it('should limit length to 200 characters', () => { - const longValue = 'a'.repeat(250); - const input = { source: longValue }; - const result = sanitizeUtmParams(input); - expect(result?.source).toHaveLength(200); - }); - - it('should preserve valid characters', () => { - const input = { - source: 'google-ads', - medium: 'cpc_campaign', - campaign: 'spring.2025', - content: 'Ad Variant 123', - term: 'error_tracker-tool', - }; - const result = sanitizeUtmParams(input); - expect(result).toEqual(input); - }); - - it('should remove entries with empty values after sanitization', () => { - const input = { source: '@#$%', medium: 'cpc' }; - const result = sanitizeUtmParams(input); - expect(result).toEqual({ medium: 'cpc' }); - }); - - it('should return undefined if all values become empty after sanitization', () => { - const input = { source: '@#$%', medium: '!@#$' }; - const result = sanitizeUtmParams(input); - expect(result).toBeUndefined(); - }); - - it('should handle non-string values by filtering them out', () => { - const input = { source: 'google', medium: 123, campaign: true } as any; - const result = sanitizeUtmParams(input); - expect(result).toEqual({ source: 'google' }); - }); - - it('should handle null and undefined values', () => { - const input = { source: 'google', medium: null as any, campaign: undefined }; - const result = sanitizeUtmParams(input); + it('should filter out undefined and null values', () => { + const result = validateUtmParams({ + source: 'google', + medium: undefined, + campaign: null, + } as any); expect(result).toEqual({ source: 'google' }); }); - it('should handle complex sanitization case', () => { + it('should validate each parameter independently', () => { const input = { - source: ' Google@Ads#Campaign ', - medium: 'cpc$paid%search', - campaign: 'spring_2025-launch.campaign', - content: '!@#$%', - term: 'error tracker tool', + source: '######', // invalid chars + medium: 'cpc', // valid + campaign: 'spring_2025_launch', // valid + content: 'ad_variant_a', // valid + term: 'error_tracker', // valid }; - const result = sanitizeUtmParams(input); + const result = validateUtmParams(input); expect(result).toEqual({ - source: 'GoogleAdsCampaign', - medium: 'cpcpaidsearch', - campaign: 'spring_2025-launch.campaign', - term: 'error tracker tool', + medium: 'cpc', + campaign: 'spring_2025_launch', + content: 'ad_variant_a', + term: 'error_tracker', }); }); }); From 271e380ec3e52b02da3b1979f338bcc102b86bbd Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 13 Aug 2025 19:53:13 +0300 Subject: [PATCH 19/21] fix --- src/resolvers/user.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 8b9911f2..57a75e8c 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -46,7 +46,6 @@ export default { { email, utm }: { email: string; utm?: UserDBScheme['utm'] }, { factories }: ResolverContextBase ): Promise { - const validatedUtm = validateUtmParams(utm); let user; From 34ea13833c61d78858e302b334c5882ed4b13577 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 13 Aug 2025 20:14:34 +0300 Subject: [PATCH 20/21] feat(users): add conditional UTM parameter inclusion in user data and update validation return type --- src/models/usersFactory.ts | 5 ++++- src/utils/utm/utm.ts | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/models/usersFactory.ts b/src/models/usersFactory.ts index adfa0852..f3c6ff70 100644 --- a/src/models/usersFactory.ts +++ b/src/models/usersFactory.ts @@ -76,9 +76,12 @@ export default class UsersFactory extends AbstractModelFactory 0) { + userData.utm = utm; + } + const userId = (await this.collection.insertOne(userData)).insertedId; const user = new UserModel({ diff --git a/src/utils/utm/utm.ts b/src/utils/utm/utm.ts index a2b47d09..c3845e01 100644 --- a/src/utils/utm/utm.ts +++ b/src/utils/utm/utm.ts @@ -19,9 +19,9 @@ const MAX_UTM_VALUE_LENGTH = 50; * @param {Object} utm - UTM parameters to validate * @returns {Object} - filtered valid UTM parameters */ -export function validateUtmParams(utm: any): Record { +export function validateUtmParams(utm: any): Record | undefined { if (!utm || typeof utm !== 'object' || Array.isArray(utm)) { - return {}; + return undefined; } const result: Record = {}; From 61a95fdd8f431c446df4c5a993d14c32057e284d Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 13 Aug 2025 20:16:58 +0300 Subject: [PATCH 21/21] test(utm): update validation tests to return undefined for null and non-object types --- test/utils/utm.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/utils/utm.test.ts b/test/utils/utm.test.ts index 90b452ea..fffbdb21 100644 --- a/test/utils/utm.test.ts +++ b/test/utils/utm.test.ts @@ -2,20 +2,20 @@ import { validateUtmParams } from '../../src/utils/utm/utm'; describe('UTM Utils', () => { describe('validateUtmParams', () => { - it('should return empty object for undefined or null utm', () => { - expect(validateUtmParams(undefined)).toEqual({}); - expect(validateUtmParams(null as any)).toEqual({}); + it('should return undefined for undefined or null utm', () => { + expect(validateUtmParams(undefined)).toBeUndefined(); + expect(validateUtmParams(null as any)).toBeUndefined(); }); it('should return empty object for empty object', () => { expect(validateUtmParams({})).toEqual({}); }); - it('should return empty object for non-object types', () => { - expect(validateUtmParams('string' as any)).toEqual({}); - expect(validateUtmParams(123 as any)).toEqual({}); - expect(validateUtmParams(true as any)).toEqual({}); - expect(validateUtmParams([] as any)).toEqual({}); + it('should return undefined for non-object types', () => { + expect(validateUtmParams('string' as any)).toBeUndefined(); + expect(validateUtmParams(123 as any)).toBeUndefined(); + expect(validateUtmParams(true as any)).toBeUndefined(); + expect(validateUtmParams([] as any)).toBeUndefined(); }); it('should filter out invalid UTM keys', () => {