Fix Mixpanel analytics review findings FRE-5281
P0: Fix validation bypass - validated properties now override raw properties P1: Add unit tests for shared-analytics package (3 test files) P1: Refactor spamshield to use shared-analytics, deprecate duplicate P2: Normalize phone numbers to E.164 before hashing P2: Add graceful error handling for missing env vars in config P3: Add singleton pattern to MixpanelService P3: Include timestamp in validated properties schema
This commit is contained in:
180
packages/shared-analytics/src/__tests__/mixpanel.service.test.ts
Normal file
180
packages/shared-analytics/src/__tests__/mixpanel.service.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
describe('MixpanelService', () => {
|
||||
let MixpanelService: any;
|
||||
let mixpanelService: any;
|
||||
let mockAnalytics: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('MIXPANEL_TOKEN', 'test-token');
|
||||
vi.stubEnv('GA4_MEASUREMENT_ID', 'G-TEST123');
|
||||
|
||||
mockAnalytics = {
|
||||
track: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
group: vi.fn(),
|
||||
flush: vi.fn(),
|
||||
};
|
||||
|
||||
vi.doMock('@segment/analytics-node', () => ({
|
||||
Analytics: vi.fn(() => mockAnalytics),
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
const config = await import('../config/analytics.config');
|
||||
if (config.analyticsEnv) {
|
||||
vi.clearAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it('implements singleton pattern via getInstance', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
MixpanelService = mod.MixpanelService;
|
||||
|
||||
const instance1 = MixpanelService.getInstance();
|
||||
const instance2 = MixpanelService.getInstance();
|
||||
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
it('exports singleton instance as mixpanelService', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
const instance = MixpanelService.getInstance();
|
||||
expect(mixpanelService).toBe(instance);
|
||||
});
|
||||
|
||||
it('validated properties override raw properties (P0 fix)', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.track(
|
||||
mod.EventType.USER_SIGNED_UP,
|
||||
'user-123',
|
||||
{
|
||||
platform: 'web',
|
||||
version: 'malicious-value',
|
||||
}
|
||||
);
|
||||
|
||||
const trackCall = mockAnalytics.track.mock.calls[0];
|
||||
const properties = trackCall[0].properties;
|
||||
|
||||
expect(properties.platform).toBe('web');
|
||||
expect(properties.version).toBe('malicious-value');
|
||||
expect(properties.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it('adds timestamp to all tracked events', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.track(mod.EventType.USER_SIGNED_UP, 'user-123', {});
|
||||
|
||||
const trackCall = mockAnalytics.track.mock.calls[0];
|
||||
expect(trackCall[0].properties.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('tracks user signup event correctly', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.userSignedUp('user-123', 'pro', 'google');
|
||||
|
||||
const trackCall = mockAnalytics.track.mock.calls[0];
|
||||
expect(trackCall[0].event).toBe('user_signed_up');
|
||||
expect(trackCall[0].distinctId).toBe('user-123');
|
||||
expect(trackCall[0].properties.plan).toBe('pro');
|
||||
expect(trackCall[0].properties.referrer).toBe('google');
|
||||
});
|
||||
|
||||
it('tracks user upgrade event correctly', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.userUpgraded('user-123', 'free', 'pro', 29.99);
|
||||
|
||||
const trackCall = mockAnalytics.track.mock.calls[0];
|
||||
expect(trackCall[0].event).toBe('user_upgraded');
|
||||
expect(trackCall[0].properties.fromTier).toBe('free');
|
||||
expect(trackCall[0].properties.toTier).toBe('pro');
|
||||
expect(trackCall[0].properties.mrr).toBe(29.99);
|
||||
});
|
||||
|
||||
it('tracks spam blocked event with hashed phone number', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.spamBlocked('user-123', '+14155552671', 0.95, 'ml');
|
||||
|
||||
const trackCall = mockAnalytics.track.mock.calls[0];
|
||||
expect(trackCall[0].event).toBe('spam_blocked');
|
||||
expect(trackCall[0].properties.phoneNumber).toMatch(/^sha256_/);
|
||||
expect(trackCall[0].properties.confidence).toBe(0.95);
|
||||
expect(trackCall[0].properties.method).toBe('ml');
|
||||
});
|
||||
|
||||
it('does not send raw phone number in spam blocked events', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
const rawPhone = '+14155552671';
|
||||
await mixpanelService.spamBlocked('user-123', rawPhone, 0.95, 'ml');
|
||||
|
||||
const trackCall = mockAnalytics.track.mock.calls[0];
|
||||
expect(trackCall[0].properties.phoneNumber).not.toBe(rawPhone);
|
||||
});
|
||||
|
||||
it('calls identify correctly', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.identify('user-123', { name: 'John Doe', plan: 'pro' });
|
||||
|
||||
expect(mockAnalytics.identify).toHaveBeenCalledWith({
|
||||
distinctId: 'user-123',
|
||||
traits: { name: 'John Doe', plan: 'pro' },
|
||||
});
|
||||
});
|
||||
|
||||
it('calls group correctly', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.group('org-123', 'organization', { name: 'Acme Corp' });
|
||||
|
||||
expect(mockAnalytics.group).toHaveBeenCalledWith({
|
||||
groupKey: 'organization',
|
||||
groupId: 'org-123',
|
||||
traits: { name: 'Acme Corp' },
|
||||
});
|
||||
});
|
||||
|
||||
it('calls flush correctly', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.flush();
|
||||
|
||||
expect(mockAnalytics.flush).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles exposure detected event', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.exposureDetected('user-123', 'breach', 'high', 'haveibeenpwned');
|
||||
|
||||
const trackCall = mockAnalytics.track.mock.calls[0];
|
||||
expect(trackCall[0].event).toBe('exposure_detected');
|
||||
expect(trackCall[0].properties.exposureType).toBe('breach');
|
||||
expect(trackCall[0].properties.severity).toBe('high');
|
||||
expect(trackCall[0].properties.source).toBe('haveibeenpwned');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user