import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { vi } from 'vitest'; describe('main Sentry telemetry gate', () => { let previousDsn: string | undefined; beforeEach(() => { previousDsn = process.env.SENTRY_DSN; process.env.SENTRY_DSN = 'https://public@example.com/1'; vi.resetModules(); }); afterEach(() => { if (previousDsn === undefined) { delete process.env.SENTRY_DSN; } else { process.env.SENTRY_DSN = previousDsn; } vi.resetModules(); }); it('does not initialize Sentry when persisted telemetry config is disabled', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-sentry-config-')); fs.writeFileSync( path.join(tempRoot, 'agent-teams-config.json'), JSON.stringify({ general: { telemetryEnabled: false } }), 'utf8' ); const { setClaudeBasePathOverride } = await import('@main/utils/pathDecoder'); setClaudeBasePathOverride(tempRoot); const sentrySdk = await import('@sentry/electron/main'); const init = vi.mocked(sentrySdk.init); init.mockClear(); const sentry = await import('@main/sentry'); expect(sentry.readPersistedTelemetryEnabled(tempRoot)).toBe(false); expect(init).not.toHaveBeenCalled(); expect(sentry.filterSentryEventForTelemetry({ ok: true })).toBeNull(); setClaudeBasePathOverride(null); fs.rmSync(tempRoot, { recursive: true, force: true }); }); it('clears user scope and drops events when telemetry is disabled', async () => { const sentry = await import('@main/sentry'); const sentryApi = { setUser: vi.fn(), setTags: vi.fn(), close: vi.fn(() => Promise.resolve(true)), }; sentry.setMainSentryApiForTesting(sentryApi); sentry.syncTelemetryFlag(false); expect(sentryApi.setUser).toHaveBeenCalledWith(null); expect(sentryApi.close).toHaveBeenCalled(); expect(sentry.filterSentryEventForTelemetry({ ok: true })).toBeNull(); }); it('returns only hashed anonymous Sentry context when telemetry is enabled', async () => { const sentry = await import('@main/sentry'); sentry.syncTelemetryFlag(true); const context = await sentry.getCurrentSentryTelemetryContext(); expect(context?.userId).toMatch(/^[a-f0-9]{64}$/); expect(Object.keys(context?.tags ?? {}).sort((a, b) => a.localeCompare(b))).toEqual([ 'app_version', 'arch', 'identity_source', 'platform', ]); }); it('does not attach high-cardinality breadcrumb data', async () => { const sentry = await import('@main/sentry'); const sentryApi = { addBreadcrumb: vi.fn(), }; sentry.setMainSentryApiForTesting(sentryApi); sentry.addMainBreadcrumb('team', 'launch', { teamName: 'private-team-name' }); expect(sentryApi.addBreadcrumb).toHaveBeenCalledWith({ category: 'team', message: 'launch', level: 'info', }); }); it('redacts sensitive fields before allowing telemetry events', async () => { const sentry = await import('@main/sentry'); sentry.syncTelemetryFlag(true); const filtered = sentry.filterSentryEventForTelemetry({ message: 'Failed for user dev@example.com in /Users/alice/private-repo', extra: { projectPath: '/Users/alice/private-repo', token: 'sk-testsecretsecretsecret', accountUuid: 'd9b2d63a-582c-4d69-8a01-90e8199f532d', }, }); const serialized = JSON.stringify(filtered); expect(serialized).not.toContain('dev@example.com'); expect(serialized).not.toContain('alice'); expect(serialized).not.toContain('private-repo'); expect(serialized).not.toContain('sk-testsecretsecretsecret'); expect(serialized).not.toContain('d9b2d63a-582c-4d69-8a01-90e8199f532d'); }); it('only exposes safe low-cardinality telemetry tags', async () => { const { getSafeSentryTelemetryTags } = await import('@main/sentry'); expect( Object.keys(getSafeSentryTelemetryTags('app-data')).sort((a, b) => a.localeCompare(b)) ).toEqual(['app_version', 'arch', 'identity_source', 'platform']); }); });