test(telemetry): add route-logic tests — containsPII, computePk, normalizeMessage, fingerprint, policyMatch, mergePolicies (34→77 tests)
This commit is contained in:
parent
dcc0befb8c
commit
20f77d5a50
@ -48,18 +48,18 @@ const PII_PATTERNS = [
|
|||||||
/\b\d{3}-\d{2}-\d{4}\b/, // SSN
|
/\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;
|
if (!PII_SCAN_ENABLED) return false;
|
||||||
return PII_PATTERNS.some(p => p.test(text));
|
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 date = new Date(occurredAt);
|
||||||
const yyyyMM = `${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
|
const yyyyMM = `${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||||
return `${productId}:${yyyyMM}:${platform}`;
|
return `${productId}:${yyyyMM}:${platform}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMessage(msg: string): string {
|
export function normalizeMessage(msg: string): string {
|
||||||
return msg
|
return msg
|
||||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<UUID>')
|
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<UUID>')
|
||||||
.replace(/\d+/g, '<N>')
|
.replace(/\d+/g, '<N>')
|
||||||
@ -68,7 +68,7 @@ function normalizeMessage(msg: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateFingerprint(event: TelemetryEventDoc): string {
|
export function generateFingerprint(event: TelemetryEventDoc): string {
|
||||||
const input = [
|
const input = [
|
||||||
event.platform,
|
event.platform,
|
||||||
event.channel,
|
event.channel,
|
||||||
@ -102,7 +102,10 @@ interface ClientContext {
|
|||||||
regionCode?: string;
|
regionCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function policyMatchesContext(policy: TelemetryCollectionPolicyDoc, ctx: ClientContext): boolean {
|
export function policyMatchesContext(
|
||||||
|
policy: TelemetryCollectionPolicyDoc,
|
||||||
|
ctx: ClientContext
|
||||||
|
): boolean {
|
||||||
const t = policy.targeting;
|
const t = policy.targeting;
|
||||||
|
|
||||||
// Check time bounds
|
// Check time bounds
|
||||||
@ -164,7 +167,7 @@ function policyMatchesContext(policy: TelemetryCollectionPolicyDoc, ctx: ClientC
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergePolicies(policies: TelemetryCollectionPolicyDoc[]): TelemetryCollectionConfig {
|
export function mergePolicies(policies: TelemetryCollectionPolicyDoc[]): TelemetryCollectionConfig {
|
||||||
if (policies.length === 0) {
|
if (policies.length === 0) {
|
||||||
// Hardcoded default
|
// Hardcoded default
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -9,7 +9,17 @@ import {
|
|||||||
CreatePolicySchema,
|
CreatePolicySchema,
|
||||||
UpdatePolicySchema,
|
UpdatePolicySchema,
|
||||||
TelemetryQuerySchema,
|
TelemetryQuerySchema,
|
||||||
|
type TelemetryEventDoc,
|
||||||
|
type TelemetryCollectionPolicyDoc,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import {
|
||||||
|
containsPII,
|
||||||
|
computePk,
|
||||||
|
normalizeMessage,
|
||||||
|
generateFingerprint,
|
||||||
|
policyMatchesContext,
|
||||||
|
mergePolicies,
|
||||||
|
} from './routes.js';
|
||||||
|
|
||||||
// ─── Minimal valid event for reuse ──────────────────────────────────
|
// ─── 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('<uuid>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces numbers', () => {
|
||||||
|
expect(normalizeMessage('Timeout after 5000ms')).toBe('timeout after <n>ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces file paths', () => {
|
||||||
|
expect(normalizeMessage('Failed at /usr/lib/speech.so')).toContain('<path>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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> = {}): 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> = {}
|
||||||
|
): 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> = {}
|
||||||
|
): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user