diff --git a/lib/__tests__/telemetry-state-read-error.test.ts b/lib/__tests__/telemetry-state-read-error.test.ts new file mode 100644 index 000000000..8c11b79d0 --- /dev/null +++ b/lib/__tests__/telemetry-state-read-error.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment node +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// A telemetry state file that couldn't be read (a torn write, a passing I/O +// error) used to be replaced with the defaults, which turned the admin's +// consent off for good. Now the read answers with the defaults and leaves +// the file alone. + +vi.mock('@/lib/admin/third-party-mode', () => ({ isNoThirdPartyTrafficOn: async () => false })); + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'bw-telemetry-state-')); + vi.stubEnv('TELEMETRY_DATA_DIR', dir); + vi.stubEnv('BULWARK_TELEMETRY', ''); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('telemetry state that cannot be read', () => { + it('keeps the stored consent', async () => { + const { effectiveConsent, loadState } = await import('@/lib/telemetry/state'); + const good = JSON.stringify({ consent: 'on', endpoint: 'https://collector.example.org/', consentedAt: '2026-10-01T00:00:00.000Z', lastSentAt: null, nextScheduledAt: null }); + mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'state.json'); + writeFileSync(file, good.slice(0, 20)); + + expect((await loadState()).consent).toBe('off'); + expect((await effectiveConsent()).consent).toBe('off'); + expect(readFileSync(file, 'utf8')).toBe(good.slice(0, 20)); + + writeFileSync(file, good); + expect((await effectiveConsent()).consent).toBe('on'); + }); + + it('still writes the default state on a fresh install', async () => { + const { loadState } = await import('@/lib/telemetry/state'); + expect((await loadState()).consent).toBe('off'); + expect(JSON.parse(readFileSync(path.join(dir, 'state.json'), 'utf8')).consent).toBe('off'); + }); +}); diff --git a/lib/telemetry/state.ts b/lib/telemetry/state.ts index 57dc68db5..cae5ccd96 100644 --- a/lib/telemetry/state.ts +++ b/lib/telemetry/state.ts @@ -64,9 +64,13 @@ export async function loadState(): Promise { return { ...DEFAULTS, ...parsed }; } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + // Unreadable is not missing: answer with the defaults (so nothing is + // sent on a guess) but leave the file alone, or a passing read error + // would switch the admin's consent off for good. logger.warn('telemetry: state read failed', { error: err instanceof Error ? err.message : String(err), }); + return { ...DEFAULTS }; } // First-ever load on a fresh install: persist the default-off state so the // instance id and (lack of) consent are stable across restarts. The admin