diff --git a/src/lib/helpers/attribution.js b/src/lib/helpers/attribution.js new file mode 100644 index 000000000..06434ecf1 --- /dev/null +++ b/src/lib/helpers/attribution.js @@ -0,0 +1,197 @@ +/** + * attribution.js + * ============== + * Write-once first-touch attribution capture (issue #4520). + * + * Captures referrer / landing path / UTM params from the very first page a + * visitor lands on, so signup() can send them along with the payload. Uses + * sessionStorage ONLY — never cookies, never localStorage, no persistent + * identifier. First-touch wins: once a record exists for the session, later + * navigations never overwrite it. + */ + +/** sessionStorage key used to persist the first-touch attribution record. */ +export const ATTRIBUTION_SS_KEY = 'attribution_v1'; + +/** Max length enforced on `referrer` / `landingPath`. */ +const URL_FIELD_MAX_LENGTH = 2048; +/** Max length enforced on each individual UTM field. */ +const UTM_FIELD_MAX_LENGTH = 256; + +/** Maps URL query param names to their camelCase wire field name. */ +const UTM_PARAM_MAP = { + utm_source: 'utmSource', + utm_medium: 'utmMedium', + utm_campaign: 'utmCampaign', + utm_term: 'utmTerm', + utm_content: 'utmContent', +}; + +/** Whitelist of the 7 known wire keys, mapped to their max length. Any other key is dropped. */ +const KNOWN_FIELDS = { + referrer: URL_FIELD_MAX_LENGTH, + landingPath: URL_FIELD_MAX_LENGTH, + utmSource: UTM_FIELD_MAX_LENGTH, + utmMedium: UTM_FIELD_MAX_LENGTH, + utmCampaign: UTM_FIELD_MAX_LENGTH, + utmTerm: UTM_FIELD_MAX_LENGTH, + utmContent: UTM_FIELD_MAX_LENGTH, +}; + +/** + * @desc Returns true when running in a browser environment with sessionStorage available. + * Guards against a throwing `sessionStorage` getter (e.g. sandboxed iframes / storage + * partitioning policies) so callers never see an uncaught exception before their own try/catch. + * @returns {boolean} + */ +function isBrowser() { + if (typeof window === 'undefined') return false; + try { + return typeof sessionStorage !== 'undefined'; + } catch { + return false; + } +} + +/** + * @desc Trim a value and cap it to maxLength. Non-string / empty-after-trim input yields undefined. + * @param {*} value - Raw value to normalise. + * @param {number} maxLength - Maximum length to keep. + * @returns {string|undefined} + */ +function trimAndCap(value, maxLength) { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + return trimmed.slice(0, maxLength); +} + +/** + * @desc Determine whether a referrer URL shares the current page's origin. + * @param {string} referrer - `document.referrer` value. + * @returns {boolean} + */ +function isSameOrigin(referrer) { + try { + return new URL(referrer).origin === window.location.origin; + } catch { + return false; + } +} + +/** Query keys whose values are credentials/single-use tokens — never persisted in landingPath. */ +const SENSITIVE_QUERY_KEY_PATTERN = /token|secret|password|code|key/i; + +/** + * @desc Rebuild a search string with credential-carrying params removed, so a landing + * URL like `/signup?inviteToken=...` never duplicates the token into stored attribution. + * @param {string} search - `window.location.search` value. + * @returns {string} Sanitized search string ('' or '?...'). + */ +function stripSensitiveParams(search) { + const params = new URLSearchParams(search); + [...params.keys()].forEach((key) => { + if (SENSITIVE_QUERY_KEY_PATTERN.test(key)) params.delete(key); + }); + const rebuilt = params.toString(); + return rebuilt ? `?${rebuilt}` : ''; +} + +/** + * @desc Sanitize a cross-origin referrer for storage: parse it, strip credential-carrying + * query params (same rule as landingPath), and cap the result. A cross-origin referrer's + * own query string can carry `token`/`code`/reset values just like the landing URL does, so + * it needs the same filtering. Malformed referrers are dropped rather than stored raw. + * @param {string} referrer - `document.referrer` value (already confirmed cross-origin). + * @returns {string|undefined} + */ +function sanitizeReferrer(referrer) { + try { + const url = new URL(referrer); + return trimAndCap(`${url.origin}${url.pathname}${stripSensitiveParams(url.search)}`, URL_FIELD_MAX_LENGTH); + } catch { + return undefined; + } +} + +/** + * @desc Build the first-touch attribution record from the current document/location. + * @returns {object|null} The record, or null when there is nothing to capture. + */ +function buildAttribution() { + const record = {}; + + const referrer = document.referrer; + if (referrer && !isSameOrigin(referrer)) { + const capped = sanitizeReferrer(referrer); + if (capped) record.referrer = capped; + } + + const landingPath = trimAndCap(`${window.location.pathname}${stripSensitiveParams(window.location.search)}`, URL_FIELD_MAX_LENGTH); + if (landingPath) record.landingPath = landingPath; + + const params = new URLSearchParams(window.location.search); + Object.entries(UTM_PARAM_MAP).forEach(([queryKey, field]) => { + const capped = trimAndCap(params.get(queryKey), UTM_FIELD_MAX_LENGTH); + if (capped) record[field] = capped; + }); + + return Object.keys(record).length > 0 ? record : null; +} + +/** + * @desc Sanitize a raw parsed attribution record read back from sessionStorage: whitelist + * to the 7 known wire keys, drop any other key, drop non-string values, re-apply trim + + * length caps. Guards against a tampered/extension-injected key reaching the strict Zod + * signup endpoint (an unexpected key would 422 the whole payload). + * @param {object} parsed - Raw parsed JSON object. + * @returns {object|null} Sanitized record, or null when nothing valid remains. + */ +function sanitizeAttribution(parsed) { + const record = {}; + Object.entries(KNOWN_FIELDS).forEach(([key, maxLength]) => { + const capped = trimAndCap(parsed[key], maxLength); + if (capped) record[key] = capped; + }); + return Object.keys(record).length > 0 ? record : null; +} + +/** + * @desc Capture first-touch attribution (referrer, landing path, UTM params) into + * sessionStorage. Write-once: if a record already exists for this session, does + * nothing. Never uses cookies or localStorage, never stores a persistent identifier. + * Safe no-op when storage/window is unavailable (private mode, SSR, unit tests). + * @returns {void} + */ +export function captureFirstTouch() { + if (!isBrowser()) return; + try { + if (sessionStorage.getItem(ATTRIBUTION_SS_KEY) !== null) return; + const record = buildAttribution(); + if (record) sessionStorage.setItem(ATTRIBUTION_SS_KEY, JSON.stringify(record)); + } catch { + // sessionStorage unavailable (private mode / sandboxed) — silent no-op + } +} + +/** + * @desc Returns the stored first-touch attribution record. + * @returns {object|null} The record, or null when absent, unavailable, or malformed. + */ +export function getAttribution() { + if (!isBrowser()) return null; + try { + const raw = sessionStorage.getItem(ATTRIBUTION_SS_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return null; + return sanitizeAttribution(parsed); + } catch { + return null; + } +} + +/** + * Exports. + */ +export default { captureFirstTouch, getAttribution }; diff --git a/src/lib/helpers/tests/attribution.unit.tests.js b/src/lib/helpers/tests/attribution.unit.tests.js new file mode 100644 index 000000000..bbd5c2e22 --- /dev/null +++ b/src/lib/helpers/tests/attribution.unit.tests.js @@ -0,0 +1,246 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { captureFirstTouch, getAttribution, ATTRIBUTION_SS_KEY } from '../attribution'; + +/** + * Real jsdom Location, captured once at module load, before any mock is installed. + * afterEach always restores to this — never to a previous mock — so the plain-object + * mock never leaks into subsequent test files (mockLocation can be called more than + * once per test, e.g. once in beforeEach and again in the test body). + */ +const realLocation = window.location; + +/** + * Swaps window.location for a plain object with the given overrides, mirroring + * the pattern used in billing.store.unit.tests.js. + * @param {object} overrides - Fields to override on window.location. + * @returns {() => void} Restores the real window.location. + */ +const mockLocation = (overrides) => { + delete window.location; + window.location = { + ...realLocation, + origin: 'https://app.example.com', + pathname: '/', + search: '', + ...overrides, + }; + return () => { window.location = realLocation; }; +}; + +/** + * Sets document.referrer for a single test. + * @param {string} value - Referrer URL to set. + * @returns {void} + */ +const setReferrer = (value) => { + Object.defineProperty(document, 'referrer', { value, configurable: true }); +}; + +describe('attribution helper', () => { + beforeEach(() => { + sessionStorage.clear(); + setReferrer(''); + mockLocation({}); + }); + + afterEach(() => { + window.location = realLocation; + }); + + describe('captureFirstTouch', () => { + it('captures landingPath from pathname + search', () => { + mockLocation({ pathname: '/pricing', search: '?plan=pro' }); + captureFirstTouch(); + expect(getAttribution()).toEqual({ landingPath: '/pricing?plan=pro' }); + }); + + it('strips credential-carrying query params from landingPath, keeps the rest', () => { + mockLocation({ pathname: '/signup', search: '?inviteToken=secret123&utm_source=news&promoCode=X1' }); + captureFirstTouch(); + const record = getAttribution(); + expect(record.landingPath).toBe('/signup?utm_source=news'); + expect(record.landingPath).not.toContain('secret123'); + expect(record.utmSource).toBe('news'); + }); + + it('captures referrer when cross-origin', () => { + mockLocation({ origin: 'https://app.example.com', pathname: '/', search: '' }); + setReferrer('https://google.com/search?q=test'); + captureFirstTouch(); + expect(getAttribution()).toMatchObject({ referrer: 'https://google.com/search?q=test' }); + }); + + it('omits referrer when same-origin', () => { + mockLocation({ origin: 'https://app.example.com', pathname: '/dashboard', search: '' }); + setReferrer('https://app.example.com/somewhere-else'); + captureFirstTouch(); + expect(getAttribution()).not.toHaveProperty('referrer'); + }); + + it('omits referrer when empty', () => { + setReferrer(''); + captureFirstTouch(); + expect(getAttribution()).not.toHaveProperty('referrer'); + }); + + it('does not throw on a malformed referrer, and drops it rather than storing unparseable input', () => { + setReferrer('not-a-valid-url'); + expect(() => captureFirstTouch()).not.toThrow(); + expect(getAttribution()).not.toHaveProperty('referrer'); + }); + + it('strips credential-carrying query params from a cross-origin referrer, keeps the rest', () => { + mockLocation({ origin: 'https://app.example.com', pathname: '/', search: '' }); + setReferrer('https://partner.example.com/reset?token=secret123&utm_source=partner'); + captureFirstTouch(); + const record = getAttribution(); + expect(record.referrer).toBe('https://partner.example.com/reset?utm_source=partner'); + expect(record.referrer).not.toContain('secret123'); + }); + + it('parses utm_* query params into camelCase fields', () => { + mockLocation({ + pathname: '/landing', + search: '?utm_source=newsletter&utm_medium=email&utm_campaign=launch&utm_term=vue&utm_content=banner', + }); + captureFirstTouch(); + expect(getAttribution()).toMatchObject({ + utmSource: 'newsletter', + utmMedium: 'email', + utmCampaign: 'launch', + utmTerm: 'vue', + utmContent: 'banner', + }); + }); + + it('omits utm fields absent from the query string', () => { + mockLocation({ pathname: '/landing', search: '?utm_source=newsletter' }); + captureFirstTouch(); + const record = getAttribution(); + expect(record.utmSource).toBe('newsletter'); + expect(record).not.toHaveProperty('utmMedium'); + expect(record).not.toHaveProperty('utmCampaign'); + }); + + it('trims and caps referrer / landingPath at 2048 chars', () => { + const longSuffix = 'a'.repeat(3000); + mockLocation({ pathname: `/${longSuffix}`, search: '' }); + setReferrer(`https://google.com/${longSuffix}`); + captureFirstTouch(); + const record = getAttribution(); + expect(record.landingPath.length).toBe(2048); + expect(record.referrer.length).toBe(2048); + }); + + it('trims and caps utm fields at 256 chars', () => { + const longUtm = 'b'.repeat(500); + mockLocation({ pathname: '/landing', search: `?utm_source=${longUtm}` }); + captureFirstTouch(); + expect(getAttribution().utmSource.length).toBe(256); + }); + + it('is write-once: a second call does not overwrite the first record', () => { + mockLocation({ pathname: '/first-page', search: '' }); + captureFirstTouch(); + mockLocation({ pathname: '/second-page', search: '' }); + captureFirstTouch(); + expect(getAttribution()).toEqual({ landingPath: '/first-page' }); + }); + + it('is a silent no-op when sessionStorage.getItem throws (private mode)', () => { + const getItemSpy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('SecurityError'); + }); + expect(() => captureFirstTouch()).not.toThrow(); + getItemSpy.mockRestore(); + }); + + it('is a silent no-op when sessionStorage.setItem throws (private mode / quota)', () => { + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + mockLocation({ pathname: '/pricing', search: '' }); + expect(() => captureFirstTouch()).not.toThrow(); + setItemSpy.mockRestore(); + }); + + it('is a no-op when window is unavailable (SSR)', () => { + const originalWindow = globalThis.window; + globalThis.window = undefined; + expect(() => captureFirstTouch()).not.toThrow(); + globalThis.window = originalWindow; + }); + + it('is a silent no-op when the sessionStorage getter itself throws (e.g. sandboxed iframe / storage partitioning)', () => { + const original = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage'); + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + get() { throw new Error('SecurityError'); }, + }); + try { + expect(() => captureFirstTouch()).not.toThrow(); + } finally { + if (original) Object.defineProperty(globalThis, 'sessionStorage', original); + else delete globalThis.sessionStorage; + } + }); + }); + + describe('getAttribution', () => { + it('returns null when nothing was captured', () => { + expect(getAttribution()).toBe(null); + }); + + it('returns the stored record', () => { + sessionStorage.setItem(ATTRIBUTION_SS_KEY, JSON.stringify({ landingPath: '/x' })); + expect(getAttribution()).toEqual({ landingPath: '/x' }); + }); + + it('returns null on malformed JSON (no throw)', () => { + sessionStorage.setItem(ATTRIBUTION_SS_KEY, '{not-json'); + expect(() => getAttribution()).not.toThrow(); + expect(getAttribution()).toBe(null); + }); + + it('returns null when sessionStorage.getItem throws (private mode)', () => { + const getItemSpy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('SecurityError'); + }); + expect(getAttribution()).toBe(null); + getItemSpy.mockRestore(); + }); + + it('returns null when the sessionStorage getter itself throws (e.g. sandboxed iframe / storage partitioning)', () => { + const original = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage'); + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + get() { throw new Error('SecurityError'); }, + }); + try { + expect(() => getAttribution()).not.toThrow(); + expect(getAttribution()).toBe(null); + } finally { + if (original) Object.defineProperty(globalThis, 'sessionStorage', original); + else delete globalThis.sessionStorage; + } + }); + + it('drops unknown keys and non-string values, keeping valid whitelisted fields (tampered record)', () => { + sessionStorage.setItem(ATTRIBUTION_SS_KEY, JSON.stringify({ + landingPath: '/pricing', + utmSource: 'newsletter', + utmMedium: 12345, + injectedByExtension: 'malicious-value', + })); + expect(getAttribution()).toEqual({ landingPath: '/pricing', utmSource: 'newsletter' }); + }); + + it('returns null when the record has no valid whitelisted string fields (fully-bogus record)', () => { + sessionStorage.setItem(ATTRIBUTION_SS_KEY, JSON.stringify({ + injectedByExtension: 'malicious-value', + somethingElse: 42, + })); + expect(getAttribution()).toBe(null); + }); + }); +}); diff --git a/src/lib/plugins/attribution.js b/src/lib/plugins/attribution.js new file mode 100644 index 000000000..a69a0d574 --- /dev/null +++ b/src/lib/plugins/attribution.js @@ -0,0 +1,21 @@ +/** + * Module dependencies. + */ +import { captureFirstTouch } from '../helpers/attribution'; + +/** + * Plugin setup. + */ +export default { + /** + * Captures write-once first-touch attribution (referrer, landing path, UTM + * params) as early as possible in the boot sequence — registered before the + * router plugin so the true landing URL is captured before any router + * redirect can run. Safe no-op when storage/window is unavailable (SSR, + * private mode, unit tests). + * @returns {void} + */ + install() { + captureFirstTouch(); + }, +}; diff --git a/src/lib/plugins/index.js b/src/lib/plugins/index.js index 776c8a5cb..0331b123e 100644 --- a/src/lib/plugins/index.js +++ b/src/lib/plugins/index.js @@ -3,6 +3,7 @@ */ import vuetify from './vuetify'; import posthog from './posthog'; +import attribution from './attribution'; import dayjs from './dayjs'; import images from './images'; import aos from './aos'; @@ -13,6 +14,7 @@ import lodash from './lodash'; * Exports. */ export default { + attribution, vuetify, posthog, dayjs, diff --git a/src/lib/plugins/tests/attribution.unit.tests.js b/src/lib/plugins/tests/attribution.unit.tests.js new file mode 100644 index 000000000..c939ae09b --- /dev/null +++ b/src/lib/plugins/tests/attribution.unit.tests.js @@ -0,0 +1,23 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockCaptureFirstTouch = vi.fn(); +vi.mock('../../helpers/attribution', () => ({ + captureFirstTouch: (...args) => mockCaptureFirstTouch(...args), +})); + +import attributionPlugin from '../attribution'; + +describe('attribution plugin', () => { + beforeEach(() => { + mockCaptureFirstTouch.mockClear(); + }); + + it('has an install method', () => { + expect(typeof attributionPlugin.install).toBe('function'); + }); + + it('calls captureFirstTouch on install', () => { + attributionPlugin.install(); + expect(mockCaptureFirstTouch).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/main.js b/src/main.js index 58a57fdba..cab581a51 100644 --- a/src/main.js +++ b/src/main.js @@ -23,6 +23,7 @@ app.config.globalProperties.config = config; app.config.globalProperties.routes = routes; app + .use(plugins.attribution) .use(head) .use(pinia) .use(appRouter) diff --git a/src/modules/auth/stores/auth.store.js b/src/modules/auth/stores/auth.store.js index c8bc75824..05ce885c2 100644 --- a/src/modules/auth/stores/auth.store.js +++ b/src/modules/auth/stores/auth.store.js @@ -8,6 +8,7 @@ import { useCoreStore } from '../../core/stores/core.store'; import { useBillingStore } from '../../billing/stores/billing.store'; import { updateAbilities } from '../../../lib/helpers/ability'; import { capture, identify, reset as analyticsReset } from '../../../lib/helpers/analytics'; +import { getAttribution } from '../../../lib/helpers/attribution'; /** * @desc Deduce firstName and lastName from an email address. @@ -248,7 +249,8 @@ export const useAuthStore = defineStore('auth', { }, /** - * @desc Sign up a new user and update auth state. + * @desc Sign up a new user and update auth state. When a first-touch attribution + * record was captured for this session, it is attached to the payload (#4520). * @param {Object} params - Signup payload (email, password, firstName, lastName) * @returns {Promise} Signup response data containing user, and optionally organization or organizationSetupRequired */ @@ -265,6 +267,10 @@ export const useAuthStore = defineStore('auth', { if (deduced.lastName) { payload.lastName = deduced.lastName; } } + // Include first-touch attribution when captured for this session — omit entirely when absent (#4520). + const attribution = getAttribution(); + if (attribution) { payload.attribution = attribution; } + const signupUrl = `${api}/${config.api.endPoints.auth}/signup${inviteToken ? `?inviteToken=${encodeURIComponent(inviteToken)}` : ''}`; try { diff --git a/src/modules/auth/tests/auth.store.unit.tests.js b/src/modules/auth/tests/auth.store.unit.tests.js index ecf039672..8c36192ee 100644 --- a/src/modules/auth/tests/auth.store.unit.tests.js +++ b/src/modules/auth/tests/auth.store.unit.tests.js @@ -37,6 +37,12 @@ vi.mock('../../../lib/helpers/analytics', () => ({ reset: (...args) => mockReset(...args), })); +// Mock attribution helper +const mockGetAttribution = vi.fn(() => null); +vi.mock('../../../lib/helpers/attribution', () => ({ + getAttribution: (...args) => mockGetAttribution(...args), +})); + describe('Auth Store', () => { beforeEach(() => { setActivePinia(createPinia()); @@ -49,6 +55,7 @@ describe('Auth Store', () => { mockCapture.mockClear(); mockIdentify.mockClear(); mockReset.mockClear(); + mockGetAttribution.mockReset().mockReturnValue(null); }); it('should initialize with default state', () => { @@ -470,6 +477,41 @@ describe('Auth Store', () => { }); describe('signup', () => { + it('includes attribution in the POST payload when captured for this session (#4520)', async () => { + const authStore = useAuthStore(); + const attribution = { landingPath: '/pricing', utmSource: 'newsletter' }; + mockGetAttribution.mockReturnValue(attribution); + const mockResponse = { + data: { + user: { id: '456', email: 'new@test.com', roles: ['user'] }, + tokenExpiresIn: Date.now() + 3600000, + }, + }; + + axios.post.mockResolvedValueOnce(mockResponse); + await authStore.signup({ email: 'new@test.com', password: 'password123' }); + + const body = axios.post.mock.calls[0][1]; + expect(body.attribution).toEqual(attribution); + }); + + it('omits attribution from the POST payload when none was captured (#4520)', async () => { + const authStore = useAuthStore(); + mockGetAttribution.mockReturnValue(null); + const mockResponse = { + data: { + user: { id: '456', email: 'new@test.com', roles: ['user'] }, + tokenExpiresIn: Date.now() + 3600000, + }, + }; + + axios.post.mockResolvedValueOnce(mockResponse); + await authStore.signup({ email: 'new@test.com', password: 'password123' }); + + const body = axios.post.mock.calls[0][1]; + expect(body).not.toHaveProperty('attribution'); + }); + it('should signup successfully and update store', async () => { const authStore = useAuthStore(); const mockResponse = { diff --git a/src/modules/legal/composables/useCookieConsent.js b/src/modules/legal/composables/useCookieConsent.js index e2253929f..4b7343054 100644 --- a/src/modules/legal/composables/useCookieConsent.js +++ b/src/modules/legal/composables/useCookieConsent.js @@ -113,12 +113,25 @@ export function useCookieConsent() { ph.set_config({ persistence: 'localStorage+cookie' }); ph.opt_in_capturing(); ph.capture('consent_given', { analytics: true }); + // Anonymous consent-decision event (#4520). Fired AFTER opt_in_capturing() + // so it goes through the same gate as consent_given above — it therefore + // carries the standard opted-in capture context, not a memory-only / + // fully anonymous one (see the reject() comment below for why the + // decline branch cannot mirror this). + ph.capture('consent_choice', { accepted: true }); } }; /** * Reject optional analytics cookies. * Persists the rejection to localStorage, updates singleton refs, and opts PostHog out. + * + * `consent_choice` is intentionally NOT emitted here (#4520 open question): + * PostHog is initialized with `opt_out_capturing_by_default: true`, so + * `posthog.capture()` is a no-op until `opt_in_capturing()` runs — and + * opting in first (even briefly, just to fire one event) persists a + * cookie/localStorage consent flag, which both weakens consent gating and + * violates the "cookieless" requirement for this event. * @returns {void} */ const reject = () => { diff --git a/src/modules/legal/tests/useCookieConsent.unit.tests.js b/src/modules/legal/tests/useCookieConsent.unit.tests.js index 54c7b1e05..adec3dbb4 100644 --- a/src/modules/legal/tests/useCookieConsent.unit.tests.js +++ b/src/modules/legal/tests/useCookieConsent.unit.tests.js @@ -105,6 +105,7 @@ describe('useCookieConsent — actions', () => { expect(posthog.set_config).toHaveBeenCalledWith({ persistence: 'localStorage+cookie' }); expect(posthog.opt_in_capturing).toHaveBeenCalledOnce(); expect(posthog.capture).toHaveBeenCalledWith('consent_given', { analytics: true }); + expect(posthog.capture).toHaveBeenCalledWith('consent_choice', { accepted: true }); }); it('reject: writes LS, sets consent, calls posthog opt_out + reset, does NOT call set_config', () => { @@ -120,6 +121,12 @@ describe('useCookieConsent — actions', () => { expect(posthog.set_config).not.toHaveBeenCalled(); }); + it('reject: does NOT emit consent_choice (blocked by opt_out_capturing_by_default, #4520 open question)', () => { + const { api, posthog } = mountComposable(); + api.reject(); + expect(posthog.capture).not.toHaveBeenCalled(); + }); + it('reopenSettings: flips consentNeeded to true without touching posthog or LS', () => { const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); localStorage.setItem(