diff --git a/app/api/setup/__tests__/step-session-secret.test.ts b/app/api/setup/__tests__/step-session-secret.test.ts index 93c0cc32e..d4478161a 100644 --- a/app/api/setup/__tests__/step-session-secret.test.ts +++ b/app/api/setup/__tests__/step-session-secret.test.ts @@ -19,6 +19,7 @@ vi.mock('@/lib/telemetry', () => ({ effectiveConsent: async () => ({ source: 'env' }), loadState: async () => ({}), saveState: async () => {}, + updateState: async () => ({}), reschedule: async () => {}, })); diff --git a/app/api/setup/__tests__/step-telemetry-consent.test.ts b/app/api/setup/__tests__/step-telemetry-consent.test.ts new file mode 100644 index 000000000..b49cbdc99 --- /dev/null +++ b/app/api/setup/__tests__/step-telemetry-consent.test.ts @@ -0,0 +1,39 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +// The wizard's telemetry choice used to load the whole state, change it and +// save it back, putting its copy over anything written meanwhile (the +// scheduler leader storing the next heartbeat, on this or another replica). +// It changes only the consent fields of what is stored now. + +vi.mock('@/lib/setup/state', () => ({ detectSetupState: () => 'bootstrap' })); +vi.mock('@/lib/setup/session', () => ({ authenticateWizardRequest: async () => true })); +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { ensureLoaded: async () => {}, setAdminConfig: async () => {} }, +})); +const stored = vi.hoisted(() => ({ consent: 'off', consentedAt: null as string | null, nextScheduledAt: 'kept' })); +vi.mock('@/lib/telemetry', () => ({ + effectiveConsent: async () => ({ source: 'file' }), + loadState: async () => { throw new Error('read-modify-write of the whole state'); }, + saveState: async () => { throw new Error('read-modify-write of the whole state'); }, + updateState: async (change: (s: typeof stored) => void) => { change(stored); return stored; }, + reschedule: async () => {}, +})); + +import { POST } from '../step/route'; + +describe('POST /api/setup/step (security, telemetry)', () => { + it('turns consent on against the stored state', async () => { + const res = await POST(new NextRequest('http://localhost/api/setup/step', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ step: 'security', values: { telemetryConsent: 'on' } }), + })); + expect(res.status).toBe(200); + expect(stored).toMatchObject({ consent: 'on', nextScheduledAt: 'kept' }); + expect(stored.consentedAt).not.toBeNull(); + }); +}); diff --git a/app/api/setup/step/route.ts b/app/api/setup/step/route.ts index 17ac852ad..33fa0dccf 100644 --- a/app/api/setup/step/route.ts +++ b/app/api/setup/step/route.ts @@ -4,7 +4,7 @@ import { authenticateWizardRequest } from '@/lib/setup/session'; import { configManager } from '@/lib/admin/config-manager'; import { CONFIG_ENV_MAP } from '@/lib/admin/types'; import { parseJmapServers } from '@/lib/admin/jmap-servers'; -import { effectiveConsent, loadState, saveState, reschedule } from '@/lib/telemetry'; +import { effectiveConsent, updateState, reschedule } from '@/lib/telemetry'; import { logger } from '@/lib/logger'; export const dynamic = 'force-dynamic'; @@ -93,12 +93,12 @@ export async function POST(request: NextRequest) { // A BULWARK_TELEMETRY env var hard-locks the choice; don't fight it. const { source } = await effectiveConsent(); if (source !== 'env') { - const tstate = await loadState(); - tstate.consent = consent; - if (consent === 'on' && !tstate.consentedAt) { - tstate.consentedAt = new Date().toISOString(); - } - await saveState(tstate); + const now = new Date().toISOString(); + // Against the stored state, so a schedule written meanwhile stays. + await updateState((tstate) => { + tstate.consent = consent; + if (consent === 'on' && !tstate.consentedAt) tstate.consentedAt = now; + }); await reschedule(); } }