mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
fix: merge hashed auth user data into GTM auth events
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
|
||||
|
||||
@@ -12,14 +13,32 @@ function lastDataLayerEntry(): Record<string, unknown> | undefined {
|
||||
return dl?.[dl.length - 1] as Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
async function flushAsyncWork() {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function toUint8Array(data: BufferSource): Uint8Array {
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return new Uint8Array(data)
|
||||
}
|
||||
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
||||
}
|
||||
|
||||
describe('GtmTelemetryProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
window.__CONFIG__ = {}
|
||||
window.dataLayer = undefined
|
||||
window.gtag = undefined
|
||||
document.head.innerHTML = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('injects the GTM runtime script', () => {
|
||||
window.__CONFIG__ = {
|
||||
gtm_container_id: 'GTM-TEST123'
|
||||
@@ -230,8 +249,20 @@ describe('GtmTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes normalized email as user_data before auth event', () => {
|
||||
it('pushes hashed email inside the auth event payload', async () => {
|
||||
const provider = createInitializedProvider()
|
||||
vi.stubGlobal('crypto', {
|
||||
subtle: {
|
||||
digest: vi.fn(
|
||||
async (_algorithm: AlgorithmIdentifier, data: BufferSource) => {
|
||||
const digest = createHash('sha256')
|
||||
.update(toUint8Array(data))
|
||||
.digest()
|
||||
return Uint8Array.from(digest).buffer
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
provider.trackAuth({
|
||||
method: 'email',
|
||||
@@ -240,21 +271,25 @@ describe('GtmTelemetryProvider', () => {
|
||||
email: ' Test@Example.com '
|
||||
})
|
||||
|
||||
const dl = window.dataLayer as Record<string, unknown>[]
|
||||
const userData = dl.find((entry) => 'user_data' in entry)
|
||||
expect(userData).toMatchObject({
|
||||
user_data: { email: 'test@example.com' }
|
||||
})
|
||||
await flushAsyncWork()
|
||||
|
||||
// Verify user_data is pushed before the sign_up event
|
||||
const userDataIndex = dl.findIndex((entry) => 'user_data' in entry)
|
||||
const signUpIndex = dl.findIndex(
|
||||
(entry) => (entry as Record<string, unknown>).event === 'sign_up'
|
||||
)
|
||||
expect(userDataIndex).toBeLessThan(signUpIndex)
|
||||
const dl = window.dataLayer as Record<string, unknown>[]
|
||||
const authEvent = dl.find((entry) => entry.event === 'sign_up')
|
||||
expect(authEvent).toMatchObject({
|
||||
event: 'sign_up',
|
||||
method: 'email',
|
||||
user_id: 'uid-123',
|
||||
user_data: {
|
||||
email: createHash('sha256').update('test@example.com').digest('hex')
|
||||
}
|
||||
})
|
||||
expect(
|
||||
dl.some((entry) => 'user_data' in entry && !('event' in entry))
|
||||
).toBe(false)
|
||||
expect(JSON.stringify(dl)).not.toContain('test@example.com')
|
||||
})
|
||||
|
||||
it('does not push user_data when email is absent', () => {
|
||||
it('omits user_data when email is absent', async () => {
|
||||
const provider = createInitializedProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
@@ -263,9 +298,35 @@ describe('GtmTelemetryProvider', () => {
|
||||
user_id: 'uid-456'
|
||||
})
|
||||
|
||||
const dl = window.dataLayer as Record<string, unknown>[]
|
||||
const userData = dl.find((entry) => 'user_data' in entry)
|
||||
expect(userData).toBeUndefined()
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'login',
|
||||
method: 'google',
|
||||
user_id: 'uid-456'
|
||||
})
|
||||
expect(lastDataLayerEntry()).not.toHaveProperty('user_data')
|
||||
})
|
||||
|
||||
it('omits user_data when hashing is unavailable', async () => {
|
||||
const provider = createInitializedProvider()
|
||||
vi.stubGlobal('crypto', undefined)
|
||||
|
||||
provider.trackAuth({
|
||||
method: 'github',
|
||||
is_new_user: false,
|
||||
user_id: 'uid-789',
|
||||
email: 'user@example.com'
|
||||
})
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'login',
|
||||
method: 'github',
|
||||
user_id: 'uid-789'
|
||||
})
|
||||
expect(lastDataLayerEntry()).not.toHaveProperty('user_data')
|
||||
})
|
||||
|
||||
it('does not push events when not initialized', () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
} from '../../types'
|
||||
import { hashEmail } from '../../utils/hashEmail'
|
||||
|
||||
/**
|
||||
* Google Tag Manager telemetry provider.
|
||||
@@ -135,23 +136,25 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
if (!this.initialized) return
|
||||
void this.pushAuthEvent(metadata)
|
||||
}
|
||||
|
||||
private async pushAuthEvent(metadata: AuthMetadata): Promise<void> {
|
||||
const basePayload = {
|
||||
method: metadata.method,
|
||||
...(metadata.user_id ? { user_id: metadata.user_id } : {})
|
||||
}
|
||||
|
||||
if (metadata.email) {
|
||||
window.dataLayer?.push({
|
||||
user_data: { email: metadata.email.trim().toLowerCase() }
|
||||
})
|
||||
}
|
||||
const hashedEmail = await hashEmail(metadata.email, 'SHA-256')
|
||||
const payload = hashedEmail
|
||||
? {
|
||||
...basePayload,
|
||||
user_data: { email: hashedEmail }
|
||||
}
|
||||
: basePayload
|
||||
|
||||
if (metadata.is_new_user) {
|
||||
this.pushEvent('sign_up', basePayload)
|
||||
return
|
||||
}
|
||||
|
||||
this.pushEvent('login', basePayload)
|
||||
this.pushEvent(metadata.is_new_user ? 'sign_up' : 'login', payload)
|
||||
}
|
||||
|
||||
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { captureCheckoutAttributionFromSearch } from '@/platform/telemetry/utils/checkoutAttribution'
|
||||
import { hashEmail } from '@/platform/telemetry/utils/hashEmail'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
@@ -85,11 +86,9 @@ export class ImpactTelemetryProvider implements TelemetryProvider {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const { customerId, customerEmail } = this.resolveCustomerIdentity()
|
||||
const normalizedEmail = customerEmail.trim().toLowerCase()
|
||||
// Impact's Identify spec requires customerEmail to be sent as a SHA1 hash.
|
||||
const hashedEmail = normalizedEmail
|
||||
? await this.hashSha1(normalizedEmail)
|
||||
: EMPTY_CUSTOMER_VALUE
|
||||
const hashedEmail =
|
||||
(await hashEmail(customerEmail, 'SHA-1')) ?? EMPTY_CUSTOMER_VALUE
|
||||
|
||||
window.ire?.('identify', {
|
||||
customerId,
|
||||
@@ -151,23 +150,4 @@ export class ImpactTelemetryProvider implements TelemetryProvider {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async hashSha1(value: string): Promise<string> {
|
||||
try {
|
||||
if (!globalThis.crypto?.subtle || typeof TextEncoder === 'undefined') {
|
||||
return EMPTY_CUSTOMER_VALUE
|
||||
}
|
||||
|
||||
const digestBuffer = await crypto.subtle.digest(
|
||||
'SHA-1',
|
||||
new TextEncoder().encode(value)
|
||||
)
|
||||
|
||||
return Array.from(new Uint8Array(digestBuffer))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
} catch {
|
||||
return EMPTY_CUSTOMER_VALUE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
70
src/platform/telemetry/utils/hashEmail.test.ts
Normal file
70
src/platform/telemetry/utils/hashEmail.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { hashEmail, normalizeEmail } from './hashEmail'
|
||||
|
||||
function toUint8Array(data: BufferSource): Uint8Array {
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return new Uint8Array(data)
|
||||
}
|
||||
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
||||
}
|
||||
|
||||
describe('hashEmail', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('normalizes email addresses before hashing', () => {
|
||||
expect(normalizeEmail(' Test@Example.com ')).toBe('test@example.com')
|
||||
expect(normalizeEmail(' ')).toBeUndefined()
|
||||
expect(normalizeEmail(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hashes normalized email with SHA-256', async () => {
|
||||
vi.stubGlobal('crypto', {
|
||||
subtle: {
|
||||
digest: vi.fn(
|
||||
async (_algorithm: AlgorithmIdentifier, data: BufferSource) => {
|
||||
const digest = createHash('sha256')
|
||||
.update(toUint8Array(data))
|
||||
.digest()
|
||||
return Uint8Array.from(digest).buffer
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
await expect(hashEmail(' Test@Example.com ', 'SHA-256')).resolves.toBe(
|
||||
createHash('sha256').update('test@example.com').digest('hex')
|
||||
)
|
||||
})
|
||||
|
||||
it('hashes normalized email with SHA-1', async () => {
|
||||
vi.stubGlobal('crypto', {
|
||||
subtle: {
|
||||
digest: vi.fn(
|
||||
async (_algorithm: AlgorithmIdentifier, data: BufferSource) => {
|
||||
const digest = createHash('sha1')
|
||||
.update(toUint8Array(data))
|
||||
.digest()
|
||||
return Uint8Array.from(digest).buffer
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
await expect(hashEmail(' Test@Example.com ', 'SHA-1')).resolves.toBe(
|
||||
createHash('sha1').update('test@example.com').digest('hex')
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined when hashing is unavailable', async () => {
|
||||
vi.stubGlobal('crypto', undefined)
|
||||
|
||||
await expect(hashEmail('test@example.com', 'SHA-256')).resolves.toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
33
src/platform/telemetry/utils/hashEmail.ts
Normal file
33
src/platform/telemetry/utils/hashEmail.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export type EmailHashAlgorithm = 'SHA-1' | 'SHA-256'
|
||||
|
||||
export function normalizeEmail(email?: string): string | undefined {
|
||||
const normalizedEmail = email?.trim().toLowerCase()
|
||||
return normalizedEmail || undefined
|
||||
}
|
||||
|
||||
export async function hashEmail(
|
||||
email: string | undefined,
|
||||
algorithm: EmailHashAlgorithm
|
||||
): Promise<string | undefined> {
|
||||
const normalizedEmail = normalizeEmail(email)
|
||||
if (
|
||||
!normalizedEmail ||
|
||||
!globalThis.crypto?.subtle ||
|
||||
typeof TextEncoder === 'undefined'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const digestBuffer = await globalThis.crypto.subtle.digest(
|
||||
algorithm,
|
||||
new TextEncoder().encode(normalizedEmail)
|
||||
)
|
||||
|
||||
return Array.from(new Uint8Array(digestBuffer))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user