From 20f77d5a5070764ee70cdb0c4db8681046ab3fb1 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Tue, 17 Feb 2026 09:42:57 -0800 Subject: [PATCH] =?UTF-8?q?test(telemetry):=20add=20route-logic=20tests=20?= =?UTF-8?q?=E2=80=94=20containsPII,=20computePk,=20normalizeMessage,=20fin?= =?UTF-8?q?gerprint,=20policyMatch,=20mergePolicies=20(34=E2=86=9277=20tes?= =?UTF-8?q?ts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/modules/telemetry/routes.ts | 15 +- .../src/modules/telemetry/telemetry.test.ts | 333 ++++++++++++++++++ 2 files changed, 342 insertions(+), 6 deletions(-) diff --git a/services/platform-service/src/modules/telemetry/routes.ts b/services/platform-service/src/modules/telemetry/routes.ts index 233d0bf9..b68546b6 100644 --- a/services/platform-service/src/modules/telemetry/routes.ts +++ b/services/platform-service/src/modules/telemetry/routes.ts @@ -48,18 +48,18 @@ const PII_PATTERNS = [ /\b\d{3}-\d{2}-\d{4}\b/, // SSN ]; -function containsPII(text: string): boolean { +export function containsPII(text: string): boolean { if (!PII_SCAN_ENABLED) return false; return PII_PATTERNS.some(p => p.test(text)); } -function computePk(productId: string, occurredAt: string, platform: string): string { +export function computePk(productId: string, occurredAt: string, platform: string): string { const date = new Date(occurredAt); const yyyyMM = `${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, '0')}`; return `${productId}:${yyyyMM}:${platform}`; } -function normalizeMessage(msg: string): string { +export function normalizeMessage(msg: string): string { return msg .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '') .replace(/\d+/g, '') @@ -68,7 +68,7 @@ function normalizeMessage(msg: string): string { .trim(); } -function generateFingerprint(event: TelemetryEventDoc): string { +export function generateFingerprint(event: TelemetryEventDoc): string { const input = [ event.platform, event.channel, @@ -102,7 +102,10 @@ interface ClientContext { regionCode?: string; } -function policyMatchesContext(policy: TelemetryCollectionPolicyDoc, ctx: ClientContext): boolean { +export function policyMatchesContext( + policy: TelemetryCollectionPolicyDoc, + ctx: ClientContext +): boolean { const t = policy.targeting; // Check time bounds @@ -164,7 +167,7 @@ function policyMatchesContext(policy: TelemetryCollectionPolicyDoc, ctx: ClientC return true; } -function mergePolicies(policies: TelemetryCollectionPolicyDoc[]): TelemetryCollectionConfig { +export function mergePolicies(policies: TelemetryCollectionPolicyDoc[]): TelemetryCollectionConfig { if (policies.length === 0) { // Hardcoded default return { diff --git a/services/platform-service/src/modules/telemetry/telemetry.test.ts b/services/platform-service/src/modules/telemetry/telemetry.test.ts index 199bea96..18c625fd 100644 --- a/services/platform-service/src/modules/telemetry/telemetry.test.ts +++ b/services/platform-service/src/modules/telemetry/telemetry.test.ts @@ -9,7 +9,17 @@ import { CreatePolicySchema, UpdatePolicySchema, TelemetryQuerySchema, + type TelemetryEventDoc, + type TelemetryCollectionPolicyDoc, } from './types.js'; +import { + containsPII, + computePk, + normalizeMessage, + generateFingerprint, + policyMatchesContext, + mergePolicies, +} from './routes.js'; // ─── Minimal valid event for reuse ────────────────────────────────── @@ -315,3 +325,326 @@ describe('TelemetryQuerySchema', () => { } }); }); + +// ─── containsPII ──────────────────────────────────────────────────── + +describe('containsPII', () => { + it('detects email addresses', () => { + expect(containsPII('Contact user@example.com for help')).toBe(true); + }); + + it('detects US phone numbers', () => { + expect(containsPII('Call 555-123-4567')).toBe(true); + expect(containsPII('Call 5551234567')).toBe(true); + }); + + it('detects credit card numbers', () => { + expect(containsPII('Card 4111 1111 1111 1111')).toBe(true); + expect(containsPII('Card 4111-1111-1111-1111')).toBe(true); + }); + + it('detects SSN', () => { + expect(containsPII('SSN 123-45-6789')).toBe(true); + }); + + it('allows clean text', () => { + expect(containsPII('Recognition failed with code 209')).toBe(false); + }); + + it('allows empty string', () => { + expect(containsPII('')).toBe(false); + }); +}); + +// ─── computePk ────────────────────────────────────────────────────── + +describe('computePk', () => { + it('computes correct pk for mid-year date', () => { + expect(computePk('lysnrai', '2026-06-15T10:00:00.000Z', 'ios')).toBe('lysnrai:202606:ios'); + }); + + it('pads single-digit months', () => { + expect(computePk('lysnrai', '2026-01-01T00:00:00.000Z', 'desktop')).toBe( + 'lysnrai:202601:desktop' + ); + }); + + it('handles December correctly', () => { + expect(computePk('mindlyst', '2026-12-31T23:59:59.000Z', 'web')).toBe('mindlyst:202612:web'); + }); + + it('uses UTC month (not local)', () => { + // Feb 1 00:00 UTC — should be 202602 regardless of local timezone + expect(computePk('lysnrai', '2026-02-01T00:00:00.000Z', 'android')).toBe( + 'lysnrai:202602:android' + ); + }); +}); + +// ─── normalizeMessage ─────────────────────────────────────────────── + +describe('normalizeMessage', () => { + it('replaces UUIDs', () => { + expect(normalizeMessage('Error for 550e8400-e29b-41d4-a716-446655440000')).toContain(''); + }); + + it('replaces numbers', () => { + expect(normalizeMessage('Timeout after 5000ms')).toBe('timeout after ms'); + }); + + it('replaces file paths', () => { + expect(normalizeMessage('Failed at /usr/lib/speech.so')).toContain(''); + }); + + it('lowercases', () => { + expect(normalizeMessage('FATAL ERROR')).toBe('fatal error'); + }); + + it('trims whitespace', () => { + expect(normalizeMessage(' error ')).toBe('error'); + }); + + it('handles empty string', () => { + expect(normalizeMessage('')).toBe(''); + }); + + it('is deterministic', () => { + const msg = 'Error 550e8400-e29b-41d4-a716-446655440000 at /foo/bar.ts line 42'; + expect(normalizeMessage(msg)).toBe(normalizeMessage(msg)); + }); +}); + +// ─── generateFingerprint ──────────────────────────────────────────── + +describe('generateFingerprint', () => { + function makeDoc(overrides: Partial = {}): TelemetryEventDoc { + return { + id: '550e8400-e29b-41d4-a716-446655440000', + productId: 'lysnrai', + anonymousInstallId: '660e8400-e29b-41d4-a716-446655440001', + sessionId: 'sess_abc', + platform: 'ios', + channel: 'keyboard_extension', + osFamily: 'ios', + appVersion: '1.0.0', + buildNumber: '26', + releaseChannel: 'beta', + eventType: 'error', + module: 'dictation', + eventName: 'recognition_failed', + occurredAt: '2026-02-17T08:00:00.000Z', + pk: 'lysnrai:202602:ios', + receivedAt: '2026-02-17T08:00:01.000Z', + ttl: 2592000, + ...overrides, + }; + } + + it('returns 16-char hex string', () => { + const fp = generateFingerprint(makeDoc()); + expect(fp).toMatch(/^[0-9a-f]{16}$/); + }); + + it('is deterministic', () => { + const doc = makeDoc(); + expect(generateFingerprint(doc)).toBe(generateFingerprint(doc)); + }); + + it('same error from different sessions produces same fingerprint', () => { + const fp1 = generateFingerprint(makeDoc({ sessionId: 'a' })); + const fp2 = generateFingerprint(makeDoc({ sessionId: 'b' })); + expect(fp1).toBe(fp2); + }); + + it('same error from different versions produces same fingerprint', () => { + const fp1 = generateFingerprint(makeDoc({ appVersion: '1.0.0' })); + const fp2 = generateFingerprint(makeDoc({ appVersion: '1.1.0' })); + expect(fp1).toBe(fp2); + }); + + it('different error name produces different fingerprint', () => { + const fp1 = generateFingerprint(makeDoc({ eventName: 'recognition_failed' })); + const fp2 = generateFingerprint(makeDoc({ eventName: 'recognition_timeout' })); + expect(fp1).not.toBe(fp2); + }); + + it('different module produces different fingerprint', () => { + const fp1 = generateFingerprint(makeDoc({ module: 'dictation' })); + const fp2 = generateFingerprint(makeDoc({ module: 'audio' })); + expect(fp1).not.toBe(fp2); + }); + + it('different errorDomain produces different fingerprint', () => { + const fp1 = generateFingerprint(makeDoc({ errorDomain: 'kAFAssistant' })); + const fp2 = generateFingerprint(makeDoc({ errorDomain: 'NSURLError' })); + expect(fp1).not.toBe(fp2); + }); + + it('normalizes numeric differences in messages', () => { + const fp1 = generateFingerprint(makeDoc({ message: 'Timeout after 5000ms' })); + const fp2 = generateFingerprint(makeDoc({ message: 'Timeout after 3000ms' })); + expect(fp1).toBe(fp2); + }); +}); + +// ─── policyMatchesContext ─────────────────────────────────────────── + +describe('policyMatchesContext', () => { + function makePolicy( + overrides: Partial = {} + ): TelemetryCollectionPolicyDoc { + return { + id: 'pol_1', + productId: 'lysnrai', + name: 'Test', + description: '', + enabled: true, + priority: 50, + eventTypes: ['error', 'fatal'], + modules: [], + samplingRate: 1.0, + targeting: {}, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + createdBy: 'admin', + ...overrides, + }; + } + + it('matches when targeting is empty (matches all)', () => { + expect(policyMatchesContext(makePolicy(), { platform: 'ios' })).toBe(true); + }); + + it('matches platform targeting', () => { + const policy = makePolicy({ targeting: { platforms: ['ios'] } }); + expect(policyMatchesContext(policy, { platform: 'ios' })).toBe(true); + expect(policyMatchesContext(policy, { platform: 'android' })).toBe(false); + }); + + it('matches channel targeting', () => { + const policy = makePolicy({ targeting: { channels: ['keyboard_extension'] } }); + expect(policyMatchesContext(policy, { channel: 'keyboard_extension' })).toBe(true); + expect(policyMatchesContext(policy, { channel: 'mobile_app' })).toBe(false); + }); + + it('matches osFamily targeting', () => { + const policy = makePolicy({ targeting: { osFamilies: ['ios', 'macos'] } }); + expect(policyMatchesContext(policy, { osFamily: 'ios' })).toBe(true); + expect(policyMatchesContext(policy, { osFamily: 'android' })).toBe(false); + }); + + it('matches userId targeting', () => { + const policy = makePolicy({ targeting: { userIds: ['usr_abc'] } }); + expect(policyMatchesContext(policy, { userId: 'usr_abc' })).toBe(true); + expect(policyMatchesContext(policy, { userId: 'usr_xyz' })).toBe(false); + }); + + it('matches appVersion targeting', () => { + const policy = makePolicy({ targeting: { appVersions: ['1.0.0', '1.1.0'] } }); + expect(policyMatchesContext(policy, { appVersion: '1.0.0' })).toBe(true); + expect(policyMatchesContext(policy, { appVersion: '2.0.0' })).toBe(false); + }); + + it('matches buildNumber range targeting', () => { + const policy = makePolicy({ targeting: { buildNumberRange: { min: 10, max: 50 } } }); + expect(policyMatchesContext(policy, { buildNumber: '25' })).toBe(true); + expect(policyMatchesContext(policy, { buildNumber: '5' })).toBe(false); + expect(policyMatchesContext(policy, { buildNumber: '100' })).toBe(false); + }); + + it('matches releaseChannel targeting', () => { + const policy = makePolicy({ targeting: { releaseChannels: ['beta'] } }); + expect(policyMatchesContext(policy, { releaseChannel: 'beta' })).toBe(true); + expect(policyMatchesContext(policy, { releaseChannel: 'prod' })).toBe(false); + }); + + it('rejects expired policy', () => { + const policy = makePolicy({ expiresAt: '2020-01-01T00:00:00.000Z' }); + expect(policyMatchesContext(policy, { platform: 'ios' })).toBe(false); + }); + + it('rejects future-only policy', () => { + const policy = makePolicy({ startsAt: '2099-01-01T00:00:00.000Z' }); + expect(policyMatchesContext(policy, { platform: 'ios' })).toBe(false); + }); + + it('matches countryCode targeting', () => { + const policy = makePolicy({ targeting: { countryCodes: ['US', 'CA'] } }); + expect(policyMatchesContext(policy, { countryCode: 'US' })).toBe(true); + expect(policyMatchesContext(policy, { countryCode: 'DE' })).toBe(false); + }); + + it('matches regionCode targeting', () => { + const policy = makePolicy({ targeting: { regionCodes: ['US:WA', 'US:CA'] } }); + expect(policyMatchesContext(policy, { regionCode: 'US:WA' })).toBe(true); + expect(policyMatchesContext(policy, { regionCode: 'US:TX' })).toBe(false); + }); +}); + +// ─── mergePolicies ────────────────────────────────────────────────── + +describe('mergePolicies', () => { + function makePolicy( + overrides: Partial = {} + ): TelemetryCollectionPolicyDoc { + return { + id: 'pol_1', + productId: 'lysnrai', + name: 'Test', + description: '', + enabled: true, + priority: 50, + eventTypes: ['error', 'fatal'], + modules: [], + samplingRate: 1.0, + targeting: {}, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + createdBy: 'admin', + ...overrides, + }; + } + + it('returns defaults when no policies', () => { + const config = mergePolicies([]); + expect(config.enabled).toBe(true); + expect(config.eventTypes).toEqual(['warn', 'error', 'fatal']); + expect(config.samplingRates.error).toBe(1); + expect(config.samplingRates.debug).toBe(0); + }); + + it('unions event types from multiple policies', () => { + const config = mergePolicies([ + makePolicy({ eventTypes: ['error'], samplingRate: 1.0 }), + makePolicy({ id: 'pol_2', eventTypes: ['debug', 'info'], samplingRate: 0.5 }), + ]); + expect(config.eventTypes).toContain('error'); + expect(config.eventTypes).toContain('debug'); + expect(config.eventTypes).toContain('info'); + }); + + it('first policy sampling rate wins per event type', () => { + const config = mergePolicies([ + makePolicy({ eventTypes: ['error'], samplingRate: 0.8 }), + makePolicy({ id: 'pol_2', eventTypes: ['error'], samplingRate: 0.2 }), + ]); + expect(config.samplingRates.error).toBe(0.8); + }); + + it('priority 999 + samplingRate 0 disables everything', () => { + const config = mergePolicies([ + makePolicy({ priority: 999, samplingRate: 0, eventTypes: ['error'] }), + ]); + expect(config.enabled).toBe(false); + }); + + it('unions modules from multiple policies', () => { + const config = mergePolicies([ + makePolicy({ modules: ['dictation'] }), + makePolicy({ id: 'pol_2', modules: ['audio', 'dictation'] }), + ]); + expect(config.modules).toContain('dictation'); + expect(config.modules).toContain('audio'); + expect(config.modules.length).toBe(2); + }); +});