diff --git a/packages/functional-tests/tests/pairing/pairAttribution.spec.ts b/packages/functional-tests/tests/pairing/pairAttribution.spec.ts new file mode 100644 index 00000000000..b62b573abc3 --- /dev/null +++ b/packages/functional-tests/tests/pairing/pairAttribution.spec.ts @@ -0,0 +1,110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * The pairing-authority approval page must report a session entrypoint. + * + * Firefox opens the approval page as a brand-new navigation carrying only + * `client_id`, `scope`, `email`, `uid`, `channel_id` and `redirect_uri` (see + * `buildAuthorityOAuthUrl`), so `/pair` stashes its attribution params and the + * approval page restores them. + */ + +import { GleanEventsHelper } from '../../lib/glean'; +import { + buildAuthorityOAuthUrl, + isPairRoutesReact, +} from '../../lib/pairing-helpers'; +import { gotoSyncSession } from '../../lib/sync-helpers'; +import { test, expect } from '../../lib/fixtures/standard'; + +// Any 32-hex value: no live supplicant is needed to render the approval page. +const MOCK_CHANNEL_ID = 'a'.repeat(32); + +test.describe('severity-2 #smoke', () => { + test.describe('Pair authority attribution', () => { + let pairRoutesReact: boolean; + test.beforeAll(async ({ browser, target }) => { + pairRoutesReact = await isPairRoutesReact(browser, target); + }); + + test.beforeEach(() => { + test.skip( + !pairRoutesReact, + 'the attribution hand-off is implemented in fxa-settings (React) only' + ); + }); + + test('carries the /pair entrypoint into the approval URL and the cad_approve_device.view ping', async ({ + target, + syncOAuthBrowserPages: { page, signin, signinTokenCode }, + testAccountTracker, + }) => { + // syncOAuthBrowserPages runs in a separate Firefox instance, so attach a + // helper to this page before any navigation. + const gleanEventsHelper = new GleanEventsHelper(page); + await gleanEventsHelper.start(); + + // /pair only reveals the choice screen to a signed-in browser. The page + // bounces through the sync OAuth flow first, carrying the entrypoint. + const credentials = await testAccountTracker.signUpSync(); + await gotoSyncSession(page, target, 'entrypoint=fxa_app_menu'); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await page.waitForURL(/signin_token_code/); + const code = await target.emailClient.getVerifyLoginCode( + credentials.email + ); + await signinTokenCode.fillOutCodeForm(code); + + // Hand off to the browser — this is what stashes the attribution params. + await page.getByTestId('has-mobile').click(); + await page.getByTestId('pair-continue-btn').click(); + + // Stand in for the navigation real Firefox makes once the supplicant + // connects. + await page.goto( + buildAuthorityOAuthUrl(target.contentServerUrl, { + email: credentials.email, + uid: credentials.uid, + channelId: MOCK_CHANNEL_ID, + }) + ); + + await expect( + page.getByRole('heading', { name: /Did you just sign in to Firefox/ }) + ).toBeVisible(); + await expect(page).toHaveURL(/entrypoint=fxa_app_menu/); + + const ping = await gleanEventsHelper.waitForEvent( + 'cad_approve_device_view' + ); + expect(ping.payload.metrics.string['session.entrypoint']).toBe( + 'fxa_app_menu' + ); + }); + + test('falls back to the preferences entrypoint when /pair was never visited', async ({ + target, + page, + testAccountTracker, + }) => { + // No /pair visit, so nothing is stashed — this is the user who started + // pairing straight from Firefox's about:preferences dialog. + const credentials = await testAccountTracker.signUpSync(); + await page.goto( + buildAuthorityOAuthUrl(target.contentServerUrl, { + email: credentials.email, + uid: credentials.uid, + channelId: MOCK_CHANNEL_ID, + }) + ); + + await expect( + page.getByRole('heading', { name: /Did you just sign in to Firefox/ }) + ).toBeVisible(); + await expect(page).toHaveURL(/entrypoint=preferences/); + }); + }); +}); diff --git a/packages/fxa-settings/src/index.tsx b/packages/fxa-settings/src/index.tsx index 5bc3acd5770..e5e51a051de 100644 --- a/packages/fxa-settings/src/index.tsx +++ b/packages/fxa-settings/src/index.tsx @@ -16,6 +16,7 @@ import { searchParams } from './lib/utilities'; import { AppContext, initializeAppContext } from './models'; import { ThemeProvider } from './models/contexts/ThemeContext'; import Storage from './lib/storage'; +import { restorePairingAttribution } from './lib/pairing-attribution'; import CookiesDisabled from './pages/CookiesDisabled'; import { BrowserRouter } from 'react-router'; import { DynamicLocalizationProvider } from './contexts/DynamicLocalizationContext'; @@ -46,6 +47,11 @@ export interface QueryParams extends FlowQueryParams { } try { + // Fx Desktop opens the pairing-authority page with none of the attribution + // params it gave /pair. Restore them from the hand-off stash before + // the router and every UrlQueryData reads the URL. + restorePairingAttribution(); + const flowQueryParams = searchParams(window.location.search) as QueryParams; // Populate config diff --git a/packages/fxa-settings/src/lib/pairing-attribution.test.ts b/packages/fxa-settings/src/lib/pairing-attribution.test.ts new file mode 100644 index 00000000000..ee08260dbd3 --- /dev/null +++ b/packages/fxa-settings/src/lib/pairing-attribution.test.ts @@ -0,0 +1,407 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + applyPairingAttribution, + isPairingAuthoritySearch, + PAIRING_ATTRIBUTION_STORAGE_KEY, + PAIRING_ATTRIBUTION_TTL_MS, + pickPairingAttribution, + pickPairingAttributionFromData, + readPairingAttribution, + restorePairingAttribution, + stashPairingAttribution, +} from './pairing-attribution'; + +const MOCK_NOW = 1_700_000_000_000; +const AUTHORITY_REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob:pair-auth-webchannel'; +const MOCK_CHANNEL_ID = 'a'.repeat(32); + +/** The six params Fx Desktop actually puts on the authority URL. */ +const AUTHORITY_SEARCH = + `?client_id=3c49430b43dfba77&scope=profile&email=user%40example.com` + + `&uid=f9416ce3703e4916a4cd6b1e665a3f1a&channel_id=${MOCK_CHANNEL_ID}` + + `&redirect_uri=${encodeURIComponent(AUTHORITY_REDIRECT_URI)}`; + +const storageKey = `__fxa_storage.${PAIRING_ATTRIBUTION_STORAGE_KEY}`; + +/** A minimal Window stand-in for the bootstrap rewrite. */ +function mockWindow(href: string) { + const replaceState = jest.fn(); + const win = { + location: { href }, + history: { state: null, replaceState }, + } as unknown as Window; + return { win, replaceState }; +} + +describe('pairing-attribution', () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe('pickPairingAttribution', () => { + it('picks every attribution param from the search string', () => { + const search = + '?entrypoint=fxa_app_menu&entrypoint_experiment=exp-1' + + '&entrypoint_variation=var-1&utm_campaign=camp&utm_content=cont' + + '&utm_medium=med&utm_source=src&utm_term=term'; + + expect(pickPairingAttribution(search)).toEqual({ + entrypoint: 'fxa_app_menu', + entrypoint_experiment: 'exp-1', + entrypoint_variation: 'var-1', + utm_campaign: 'camp', + utm_content: 'cont', + utm_medium: 'med', + utm_source: 'src', + utm_term: 'term', + }); + }); + + it('ignores params that are not attribution params', () => { + expect(pickPairingAttribution(AUTHORITY_SEARCH)).toEqual({}); + }); + + it('returns an empty object for an empty search string', () => { + expect(pickPairingAttribution('')).toEqual({}); + }); + + it('falls back to the capital-P entryPoint Fx Desktop sometimes sends', () => { + expect(pickPairingAttribution('?entryPoint=fx-view')).toEqual({ + entrypoint: 'fx-view', + }); + }); + + // Matches IntegrationFactory.initIntegration(), which lets the capital-P + // value win — so /pair and the approval page report the same entrypoint. + it('prefers entryPoint over entrypoint when both are present', () => { + expect( + pickPairingAttribution('?entrypoint=fxa_app_menu&entryPoint=fx-view') + ).toEqual({ entrypoint: 'fx-view' }); + }); + }); + + describe('pickPairingAttributionFromData', () => { + it('maps every camelCase integration field to its URL param name', () => { + expect( + pickPairingAttributionFromData({ + entrypoint: 'fxa_app_menu', + entrypointExperiment: 'exp-1', + entrypointVariation: 'var-1', + utmCampaign: 'camp', + utmContent: 'cont', + utmMedium: 'med', + utmSource: 'src', + utmTerm: 'term', + }) + ).toEqual({ + entrypoint: 'fxa_app_menu', + entrypoint_experiment: 'exp-1', + entrypoint_variation: 'var-1', + utm_campaign: 'camp', + utm_content: 'cont', + utm_medium: 'med', + utm_source: 'src', + utm_term: 'term', + }); + }); + + it('omits fields the integration does not carry', () => { + expect( + pickPairingAttributionFromData({ entrypoint: 'fxa_app_menu' }) + ).toEqual({ entrypoint: 'fxa_app_menu' }); + }); + + it('returns an empty object for integration data with no attribution', () => { + expect(pickPairingAttributionFromData({})).toEqual({}); + }); + }); + + describe('isPairingAuthoritySearch', () => { + it.each([ + { + name: 'the pairing authority redirect_uri', + search: `?redirect_uri=${encodeURIComponent(AUTHORITY_REDIRECT_URI)}`, + expected: true, + }, + { + name: 'the pairing supplicant redirect_uri', + search: + '?redirect_uri=' + + encodeURIComponent('urn:ietf:wg:oauth:2.0:oob:pair-supp-webchannel'), + expected: false, + }, + { + name: 'a regular https redirect_uri', + search: + '?redirect_uri=' + encodeURIComponent('https://example.com/callback'), + expected: false, + }, + { + name: 'no redirect_uri at all', + search: '?client_id=abc', + expected: false, + }, + { name: 'an empty search string', search: '', expected: false }, + ])('returns $expected for $name', ({ search, expected }) => { + expect(isPairingAuthoritySearch(search)).toBe(expected); + }); + }); + + describe('stashPairingAttribution and readPairingAttribution', () => { + it('round-trips the attribution params', () => { + stashPairingAttribution( + { + entrypoint: 'send-tab-toolbar-icon', + utm_source: 'firefox-browser', + }, + MOCK_NOW + ); + + expect(readPairingAttribution(MOCK_NOW)).toEqual({ + entrypoint: 'send-tab-toolbar-icon', + utm_source: 'firefox-browser', + }); + }); + + it('stores nothing when there is no attribution to carry', () => { + stashPairingAttribution({}, MOCK_NOW); + + expect(localStorage.getItem(storageKey)).toBeNull(); + expect(readPairingAttribution(MOCK_NOW)).toEqual({}); + }); + + it('returns the stash one millisecond before it expires', () => { + stashPairingAttribution({ entrypoint: 'fxa_app_menu' }, MOCK_NOW); + + expect( + readPairingAttribution(MOCK_NOW + PAIRING_ATTRIBUTION_TTL_MS - 1) + ).toEqual({ entrypoint: 'fxa_app_menu' }); + }); + + it('returns an empty object once the stash has expired', () => { + stashPairingAttribution({ entrypoint: 'fxa_app_menu' }, MOCK_NOW); + + expect( + readPairingAttribution(MOCK_NOW + PAIRING_ATTRIBUTION_TTL_MS) + ).toEqual({}); + }); + + it('returns an empty object when nothing was ever stashed', () => { + expect(readPairingAttribution(MOCK_NOW)).toEqual({}); + }); + + it('returns an empty object when the stored value is malformed', () => { + localStorage.setItem(storageKey, 'not json'); + + expect(readPairingAttribution(MOCK_NOW)).toEqual({}); + }); + + it('returns an empty object when the stored value is missing createdAt', () => { + localStorage.setItem( + storageKey, + JSON.stringify({ params: { entrypoint: 'fxa_app_menu' } }) + ); + + expect(readPairingAttribution(MOCK_NOW)).toEqual({}); + }); + + it('ignores stored keys that are not attribution params', () => { + localStorage.setItem( + storageKey, + JSON.stringify({ + params: { entrypoint: 'fxa_app_menu', client_id: 'deadbeef' }, + createdAt: MOCK_NOW, + }) + ); + + expect(readPairingAttribution(MOCK_NOW)).toEqual({ + entrypoint: 'fxa_app_menu', + }); + }); + + it('replaces an earlier stash', () => { + stashPairingAttribution({ entrypoint: 'fxa_app_menu' }, MOCK_NOW); + stashPairingAttribution( + { entrypoint: 'send-tab-account-menu' }, + MOCK_NOW + ); + + expect(readPairingAttribution(MOCK_NOW)).toEqual({ + entrypoint: 'send-tab-account-menu', + }); + }); + + it('does not clear the stash on read, so repeat loads agree', () => { + stashPairingAttribution({ entrypoint: 'fxa_app_menu' }, MOCK_NOW); + + readPairingAttribution(MOCK_NOW); + + expect(readPairingAttribution(MOCK_NOW)).toEqual({ + entrypoint: 'fxa_app_menu', + }); + }); + + it('does not throw when localStorage is unavailable', () => { + jest.isolateModules(() => { + jest.doMock('./storage', () => ({ + __esModule: true, + default: { + factory: () => ({ + get: () => { + throw new Error('localStorage is disabled'); + }, + set: () => { + throw new Error('localStorage is disabled'); + }, + }), + }, + })); + + const { + readPairingAttribution: read, + stashPairingAttribution: stash, + } = require('./pairing-attribution'); + + expect(() => + stash({ entrypoint: 'fxa_app_menu' }, MOCK_NOW) + ).not.toThrow(); + expect(read(MOCK_NOW)).toEqual({}); + }); + }); + }); + + describe('applyPairingAttribution', () => { + it('carries the stashed params onto the authority search', () => { + const result = applyPairingAttribution(AUTHORITY_SEARCH, { + entrypoint: 'send-tab-toolbar-icon', + utm_source: 'firefox-browser', + }); + + const params = new URLSearchParams(result!); + expect(params.get('entrypoint')).toBe('send-tab-toolbar-icon'); + expect(params.get('utm_source')).toBe('firefox-browser'); + }); + + it('preserves the params Fx Desktop supplied', () => { + const result = applyPairingAttribution(AUTHORITY_SEARCH, { + entrypoint: 'send-tab-toolbar-icon', + }); + + const params = new URLSearchParams(result!); + expect(params.get('channel_id')).toBe(MOCK_CHANNEL_ID); + expect(params.get('redirect_uri')).toBe(AUTHORITY_REDIRECT_URI); + expect(params.get('email')).toBe('user@example.com'); + }); + + it('defaults the entrypoint to preferences when nothing was stashed', () => { + const result = applyPairingAttribution(AUTHORITY_SEARCH, {}); + + expect(new URLSearchParams(result!).get('entrypoint')).toBe( + 'preferences' + ); + }); + + it('defaults the entrypoint but still carries utm params when only utm was stashed', () => { + const result = applyPairingAttribution(AUTHORITY_SEARCH, { + utm_campaign: 'camp', + }); + + const params = new URLSearchParams(result!); + expect(params.get('entrypoint')).toBe('preferences'); + expect(params.get('utm_campaign')).toBe('camp'); + }); + + it('returns null when the search already has an entrypoint', () => { + expect( + applyPairingAttribution(`${AUTHORITY_SEARCH}&entrypoint=fx-view`, { + entrypoint: 'fxa_app_menu', + }) + ).toBeNull(); + }); + + it('does not overwrite a utm param already in the search', () => { + const result = applyPairingAttribution( + `${AUTHORITY_SEARCH}&utm_source=from-url`, + { entrypoint: 'fxa_app_menu', utm_source: 'from-stash' } + ); + + expect(new URLSearchParams(result!).get('utm_source')).toBe('from-url'); + }); + + it('returns null when the search is not a pairing authority URL', () => { + expect( + applyPairingAttribution('?client_id=3c49430b43dfba77', { + entrypoint: 'fxa_app_menu', + }) + ).toBeNull(); + }); + }); + + describe('restorePairingAttribution', () => { + it('rewrites the URL with the stashed entrypoint', () => { + stashPairingAttribution( + { entrypoint: 'send-tab-toolbar-icon' }, + MOCK_NOW + ); + const { win, replaceState } = mockWindow( + `https://accounts.firefox.com/oauth${AUTHORITY_SEARCH}` + ); + + expect(restorePairingAttribution(win, MOCK_NOW)).toBe(true); + expect(replaceState).toHaveBeenCalledTimes(1); + expect(replaceState.mock.calls[0][2]).toContain( + 'entrypoint=send-tab-toolbar-icon' + ); + }); + + it('preserves the pathname and hash when rewriting', () => { + const { win, replaceState } = mockWindow( + `https://accounts.firefox.com/oauth${AUTHORITY_SEARCH}#some-hash` + ); + + restorePairingAttribution(win, MOCK_NOW); + + const url = new URL(replaceState.mock.calls[0][2]); + expect(url.pathname).toBe('/oauth'); + expect(url.hash).toBe('#some-hash'); + }); + + it('falls back to the preferences entrypoint when nothing was stashed', () => { + const { win, replaceState } = mockWindow( + `https://accounts.firefox.com/oauth${AUTHORITY_SEARCH}` + ); + + expect(restorePairingAttribution(win, MOCK_NOW)).toBe(true); + expect(replaceState.mock.calls[0][2]).toContain('entrypoint=preferences'); + }); + + it('does not rewrite the URL on a non-pairing route', () => { + stashPairingAttribution({ entrypoint: 'fxa_app_menu' }, MOCK_NOW); + const { win, replaceState } = mockWindow( + 'https://accounts.firefox.com/signin?client_id=3c49430b43dfba77' + ); + + expect(restorePairingAttribution(win, MOCK_NOW)).toBe(false); + expect(replaceState).not.toHaveBeenCalled(); + }); + + it('returns false instead of throwing when replaceState fails', () => { + const win = { + location: { + href: `https://accounts.firefox.com/oauth${AUTHORITY_SEARCH}`, + }, + history: { + state: null, + replaceState: () => { + throw new Error('SecurityError'); + }, + }, + } as unknown as Window; + + expect(restorePairingAttribution(win, MOCK_NOW)).toBe(false); + }); + }); +}); diff --git a/packages/fxa-settings/src/lib/pairing-attribution.ts b/packages/fxa-settings/src/lib/pairing-attribution.ts new file mode 100644 index 00000000000..6058e081442 --- /dev/null +++ b/packages/fxa-settings/src/lib/pairing-attribution.ts @@ -0,0 +1,313 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Carries attribution query params across the browser-initiated hand-off in the + * device pairing flow. + * + * The desktop `/pair` page is opened by Firefox with an entrypoint + * (send-tab-*`, fxa_app_menu, etc). Clicking through sends the + * `fxaccounts:pair_preferences` WebChannel command and control passes to browser + * chrome, which renders the pairing QR code. + * + * When the supplicant connects, Firefox opens a brand-new top-level navigation to + * `/oauth?client_id=…&scope=…&email=…&uid=…&channel_id=…&redirect_uri=urn:…oob:pair-auth-webchannel` + * This has six params, none of them attribution. So the authority approval page has no + * entrypoint to propagate and `session.entrypoint` is empty on + * `cad_approve_device.view` abd submit. + */ + +import Storage from './storage'; +import { Constants } from './constants'; + +let storageInstance: Storage | undefined; +function storage(): Storage { + if (storageInstance === undefined) { + storageInstance = Storage.factory('localStorage'); + } + return storageInstance; +} + +/** + * Attribution query params carried from `/pair` to the pairing-authority pages. + * These are exactly the params Glean reads off the integration data + * (see `lib/glean/index.ts` `initMetrics()`). + */ +export const PAIRING_ATTRIBUTION_PARAMS = [ + 'entrypoint', + 'entrypoint_experiment', + 'entrypoint_variation', + 'utm_campaign', + 'utm_content', + 'utm_medium', + 'utm_source', + 'utm_term', +] as const; + +export const PAIRING_ATTRIBUTION_STORAGE_KEY = 'pairing_attribution'; + +/** + * How long a stashed hand-off stays valid. The journey is `/pair` → Firefox's + * pairing dialog → (possibly install Firefox on the phone) → scan → approve, so + * this needs to tolerate tens of minutes. Keeping it inside one plausible session + * bounds how long a *later* pairing started straight from `about:preferences` + * could be mis-attributed to the earlier one. + */ +export const PAIRING_ATTRIBUTION_TTL_MS = 30 * 60 * 1000; + +export type PairingAttributionParam = + (typeof PAIRING_ATTRIBUTION_PARAMS)[number]; + +export type PairingAttribution = Partial< + Record +>; + +type StoredPairingAttribution = { + params: PairingAttribution; + createdAt: number; +}; + +/** + * True when this search string is the Fx Desktop pairing-authority entry point. + * + * Mirrors `DefaultIntegrationFlags.isDevicePairingAsAuthority()` in + * `lib/integrations/integration-factory-flags.ts` — kept as a pure function over a + * search string so it can run at bootstrap, before any `ModelDataStore` exists. + * Keep the two in sync. + */ +export function isPairingAuthoritySearch(search: string): boolean { + return ( + new URLSearchParams(search).get('redirect_uri') === + Constants.DEVICE_PAIRING_AUTHORITY_REDIRECT_URI + ); +} + +/** Picks the attribution params out of a search string. */ +export function pickPairingAttribution(search: string): PairingAttribution { + const params = new URLSearchParams(search); + const picked: PairingAttribution = {}; + + for (const name of PAIRING_ATTRIBUTION_PARAMS) { + const value = params.get(name); + if (value) { + picked[name] = value; + } + } + + // Fx Desktop declares both `entryPoint` (capital P) and `entrypoint`. + // `IntegrationFactory.initIntegration()` lets the capital P value win. + const entryPoint = params.get('entryPoint'); + if (entryPoint) { + picked.entrypoint = entryPoint; + } + + return picked; +} + +/** + * The integration data fields that correspond to the attribution params. + */ +export type PairingAttributionData = { + entrypoint?: string; + entrypointExperiment?: string; + entrypointVariation?: string; + utmCampaign?: string; + utmContent?: string; + utmMedium?: string; + utmSource?: string; + utmTerm?: string; +}; + +const DATA_FIELD_BY_PARAM: Record< + PairingAttributionParam, + keyof PairingAttributionData +> = { + entrypoint: 'entrypoint', + entrypoint_experiment: 'entrypointExperiment', + entrypoint_variation: 'entrypointVariation', + utm_campaign: 'utmCampaign', + utm_content: 'utmContent', + utm_medium: 'utmMedium', + utm_source: 'utmSource', + utm_term: 'utmTerm', +}; + +/** + * Picks the attribution params off an integration's data. + * + * Preferred over reading the URL directly! The integration is what the page's + * own Glean events attribute with, so stashing the same value keeps `/pair` and + * the approval page reporting one entrypoint. It has also already normalized + * `entryPoint` → `entrypoint`. + */ +export function pickPairingAttributionFromData( + data: PairingAttributionData +): PairingAttribution { + const picked: PairingAttribution = {}; + + for (const name of PAIRING_ATTRIBUTION_PARAMS) { + const value = data[DATA_FIELD_BY_PARAM[name]]; + if (value) { + picked[name] = value; + } + } + + return picked; +} + +/** + * Persists attribution params for the pairing-authority page to pick up. + * No-op when there is nothing to carry. + */ +export function stashPairingAttribution( + params: PairingAttribution, + now: number = Date.now() +): void { + if (Object.keys(params).length === 0) { + return; + } + + const stored: StoredPairingAttribution = { params, createdAt: now }; + try { + storage().set(PAIRING_ATTRIBUTION_STORAGE_KEY, stored); + } catch { + // localStorage may be unavailable (disabled, private browsing, etc.) + } +} + +/** + * Reads the stashed attribution params. Returns an empty object when absent, + * expired, or malformed. + * + * Deliberately does not clear the stash — a second approval page load in the same + * session must report the same attribution, otherwise `.view` and `.submit` would + * disagree. + */ +export function readPairingAttribution( + now: number = Date.now() +): PairingAttribution { + let stored: StoredPairingAttribution | undefined; + try { + stored = storage().get(PAIRING_ATTRIBUTION_STORAGE_KEY); + } catch { + // localStorage may be unavailable + } + + if ( + !stored || + typeof stored.createdAt !== 'number' || + typeof stored.params !== 'object' || + stored.params === null + ) { + return {}; + } + + if (now - stored.createdAt >= PAIRING_ATTRIBUTION_TTL_MS) { + return {}; + } + + // Only trust the params we recognise — the stash is user-writable storage. + const params: PairingAttribution = {}; + for (const name of PAIRING_ATTRIBUTION_PARAMS) { + const value = stored.params[name]; + if (typeof value === 'string' && value) { + params[name] = value; + } + } + + return params; +} + +/** + * Merges `stashed` into `search`, defaulting `entrypoint` so the approval page + * always reports one. Params already present in the URL always win. + * + * Appends to the existing query string rather than re-serializing it: + * `public/query-fix.js` has already normalized these params with + * `encodeURIComponent`, and `URLSearchParams.toString()` uses form encoding + * (space → `+`), so a round-trip would silently rewrite params we don't own. + * + * @returns the new search string (with a leading `?`), or null when nothing + * should change. + */ +export function applyPairingAttribution( + search: string, + stashed: PairingAttribution +): string | null { + if (!isPairingAuthoritySearch(search)) { + return null; + } + + const existing = new URLSearchParams(search); + const additions: string[] = []; + + for (const name of PAIRING_ATTRIBUTION_PARAMS) { + const value = stashed[name]; + + // Important, only backfill missing query parameters. Never overwrite them! + if (value && !existing.get(name)) { + additions.push(`${name}=${encodeURIComponent(value)}`); + } + } + + // Handle about:preferences edge case + if (!existing.get('entrypoint') && !stashed.entrypoint) { + // Nothing was stashed, so pairing was started straight from Firefox's + // about:preferences dialog rather than from /pair. + additions.push( + `entrypoint=${encodeURIComponent( + Constants.FIREFOX_PREFERENCES_ENTRYPOINT + )}` + ); + } + + if (additions.length === 0) { + return null; + } + + const base = search.startsWith('?') ? search.slice(1) : search; + return `?${base ? `${base}&` : ''}${additions.join('&')}`; +} + +/** + * Bootstrap step: restores the pairing attribution params onto the current URL. + * + * Must run before the router mounts and before any `UrlQueryData` is built — + * `UrlQueryData` writes via a raw `history.replaceState` that react-router never + * observes, so a later write would be clobbered by the first + * `navigateWithQuery()`. Mirrors `public/query-fix.js`. + * + * @returns whether the URL was rewritten. Never throws. + */ +export function restorePairingAttribution( + win: Window = window, + now: number = Date.now() +): boolean { + try { + const url = new URL(win.location.href); + + // Bail before touching storage: this runs on every page load, and + // Storage.factory() probes localStorage with a write/remove. + if (!isPairingAuthoritySearch(url.search)) { + return false; + } + + const search = applyPairingAttribution( + url.search, + readPairingAttribution(now) + ); + + if (search === null) { + return false; + } + + // Rebuild from href so pathname and hash survive the rewrite. + url.search = search; + win.history.replaceState(win.history.state, '', url.toString()); + return true; + } catch { + // This runs before the app renders; a throw here would blank the page. + return false; + } +} diff --git a/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.test.tsx b/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.test.tsx index 04a1198c805..9ac7de358a0 100644 --- a/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.test.tsx +++ b/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.test.tsx @@ -2,7 +2,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; +import * as ReactUtils from 'fxa-react/lib/utils'; +import firefox from '../../lib/channels/firefox'; import { MOCK_ACCOUNT, renderWithRouter } from '../../models/mocks'; // import { getFtlBundle, testAllL10n } from 'fxa-react/lib/test-utils'; // import { FluentBundle } from '@fluent/bundle'; @@ -30,12 +32,32 @@ jest.mock('../../lib/glean', () => ({ }, })); +jest.mock('../../lib/channels/firefox', () => ({ + __esModule: true, + default: { + requestSignedInUser: jest.fn().mockResolvedValue(undefined), + fxaOAuthFlowBegin: jest.fn().mockResolvedValue(null), + }, + buildSyncOAuthSearch: jest.requireActual('../../lib/channels/firefox') + .buildSyncOAuthSearch, +})); + describe('ConnectAnotherDevice', () => { + const requestSignedInUserMock = jest.mocked(firefox.requestSignedInUser); + // let bundle: FluentBundle; beforeAll(async () => { global.URL.createObjectURL = jest.fn(); // bundle = await getFtlBundle('settings'); }); + + // This package sets neither `clearMocks` nor `resetMocks`, so re-establish the + // WebChannel default here rather than relying on the module factory surviving + // whatever a previous test did to the mock. + beforeEach(() => { + requestSignedInUserMock.mockReset(); + requestSignedInUserMock.mockResolvedValue(undefined); + }); it('renders default content as expected', () => { renderWithRouter( @@ -155,4 +177,51 @@ describe('ConnectAnotherDevice', () => { renderWithRouter(); expect(usePageViewEvent).toHaveBeenCalledWith(viewName, REACT_ENTRYPOINT); }); + + // FXA-14132: /pair attributes off its entrypoint, so the redirect must not + // drop the query params CAD was opened with. + describe('redirect to /pair', () => { + let hardNavigateSpy: jest.SpyInstance; + + beforeEach(() => { + hardNavigateSpy = jest + .spyOn(ReactUtils, 'hardNavigate') + .mockImplementation(() => {}); + requestSignedInUserMock.mockResolvedValue({ + uid: MOCK_ACCOUNT.uid, + email: MOCK_ACCOUNT.primaryEmail.email, + sessionToken: 'a'.repeat(64), + verified: true, + }); + }); + + afterEach(() => { + hardNavigateSpy.mockRestore(); + }); + + it('passes includeCurrentQueryParams when redirecting an eligible browser', async () => { + renderWithRouter(, { + route: + '/connect_another_device?context=fx_desktop_v3&entrypoint=fxa_app_menu', + }); + + await waitFor(() => + expect(hardNavigateSpy).toHaveBeenCalledWith('/pair', {}, true) + ); + }); + + it('does not redirect to /pair when the entrypoint is not a pairing entrypoint', async () => { + renderWithRouter(, { + route: + '/connect_another_device?context=fx_desktop_v3&entrypoint=ios_settings_manage', + }); + + await waitFor(() => expect(requestSignedInUserMock).toHaveBeenCalled()); + // Assert on the destination only: a regression to hardNavigate('/pair') + // would still satisfy a not.toHaveBeenCalledWith('/pair', {}, true). + expect(hardNavigateSpy.mock.calls.map((call) => call[0])).not.toContain( + '/pair' + ); + }); + }); }); diff --git a/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.tsx b/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.tsx index 2c431aed6d1..9b76da2b8b2 100644 --- a/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.tsx +++ b/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.tsx @@ -218,7 +218,9 @@ const ConnectAnotherDevice = ({ signedInUser?.sessionToken && signedInUser.verified ); if (browserSignedIn && isEligibleForPairing()) { - hardNavigate('/pair'); + // Carry the query params forward so /pair keeps the entrypoint it was + // opened with — the pairing flow attributes off it (FXA-14132). + hardNavigate('/pair', {}, true); return; } if (browserSignedIn) { diff --git a/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx b/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx index d9e5bca2641..f12bca6b2c4 100644 --- a/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx +++ b/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx @@ -4,11 +4,13 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { renderWithRouter } from '../../../models/mocks'; +import { readPairingAttribution } from '../../../lib/pairing-attribution'; import { usePageViewEvent } from '../../../lib/metrics'; import { REACT_ENTRYPOINT } from '../../../constants'; import GleanMetrics from '../../../lib/glean'; -import firefox from '../../../lib/channels/firefox'; +import firefox, { type SignedInUser } from '../../../lib/channels/firefox'; import * as ReactUtils from 'fxa-react/lib/utils'; import { MOCK_ERROR } from './mocks'; import { MOCK_CMS_INFO } from '../../mocks'; @@ -19,12 +21,13 @@ jest.mock('../../../lib/metrics', () => ({ })); let mockLocationState: unknown = null; +let mockLocationSearch = ''; const mockNavigate = jest.fn(); jest.mock('react-router', () => ({ ...jest.requireActual('react-router'), useLocation: () => ({ pathname: '/pair', - search: '', + search: mockLocationSearch, state: mockLocationState, }), useNavigate: () => mockNavigate, @@ -34,13 +37,9 @@ jest.mock('../../../lib/channels/firefox', () => ({ __esModule: true, default: { send: jest.fn(), - requestSignedInUser: jest.fn().mockResolvedValue({ - uid: 'sync-uid', - email: 'sync@example.com', - sessionToken: 'token', - verified: true, - }), - fxaOAuthFlowBegin: jest.fn().mockResolvedValue(null), + // No defaults here on purpose — `beforeEach` owns them; see the note there. + requestSignedInUser: jest.fn(), + fxaOAuthFlowBegin: jest.fn(), }, buildSyncOAuthSearch: jest.requireActual('../../../lib/channels/firefox') .buildSyncOAuthSearch, @@ -76,6 +75,14 @@ jest.mock('../../../components/QRCode', () => ({ }) => {localizedLabel}, })); +/** The signed-in browser every test starts from; see the `beforeEach` below. */ +const MOCK_SYNC_SIGNED_IN_USER: SignedInUser = { + uid: 'sync-uid', + email: 'sync@example.com', + sessionToken: 'token', + verified: true, +}; + const sendTabIntegration = { data: { entrypoint: 'send-tab-toolbar-icon' }, } as unknown as React.ComponentProps['integration']; @@ -102,9 +109,25 @@ describe('Pair', () => { }); }); + beforeEach(() => { + localStorage.clear(); + // `jest.clearAllMocks()` clears calls but neither implementations nor queued + // `mockResolvedValueOnce` values, and this package sets neither `clearMocks` + // nor `resetMocks` — so WebChannel overrides would otherwise leak into later + // tests. Reset fully, then re-establish the defaults here; this hook is the + // single source of truth for them, so the module factory declares none. + jest.mocked(firefox.requestSignedInUser).mockReset(); + jest.mocked(firefox.fxaOAuthFlowBegin).mockReset(); + jest + .mocked(firefox.requestSignedInUser) + .mockResolvedValue(MOCK_SYNC_SIGNED_IN_USER); + jest.mocked(firefox.fxaOAuthFlowBegin).mockResolvedValue(null); + }); + afterEach(() => { jest.clearAllMocks(); mockLocationState = null; + mockLocationSearch = ''; }); // Render Pair and wait for the bootstrap spinner to clear before asserting. @@ -399,8 +422,10 @@ describe('Pair', () => { }); it('reveals the choice screen when WebChannel never replies', async () => { - requestSignedInUserMock.mockResolvedValueOnce(undefined); - fxaOAuthFlowBeginMock.mockResolvedValueOnce(null); + // The bootstrap asks twice (initial + one retry), so both replies must be + // empty to reach the OAuth branch this test is about. + requestSignedInUserMock.mockResolvedValue(undefined); + fxaOAuthFlowBeginMock.mockResolvedValue(null); await renderPair(); expect( screen.getByLabelText(/I already have Firefox for mobile/) @@ -408,8 +433,8 @@ describe('Pair', () => { }); it('reveals the choice screen when fxa_status throws and OAuth never replies', async () => { - requestSignedInUserMock.mockRejectedValueOnce(new Error('boom')); - fxaOAuthFlowBeginMock.mockResolvedValueOnce(null); + requestSignedInUserMock.mockRejectedValue(new Error('boom')); + fxaOAuthFlowBeginMock.mockResolvedValue(null); await renderPair(); expect( screen.getByLabelText(/I already have Firefox for mobile/) @@ -488,6 +513,110 @@ describe('Pair', () => { }); }); + // FXA-14132: Firefox opens the pairing-authority approval page as a fresh + // navigation carrying none of this page's attribution params, so they are + // stashed here at the moment control passes to the browser. + describe('pairing attribution hand-off', () => { + const attributionIntegration = { + data: { + entrypoint: 'send-tab-toolbar-icon', + utmSource: 'firefox-browser', + }, + } as unknown as React.ComponentProps['integration']; + + it('stashes the integration attribution when Continue is clicked with "has mobile"', async () => { + const user = userEvent.setup(); + await renderPair({ integration: attributionIntegration }); + + await user.click( + screen.getByLabelText(/I already have Firefox for mobile/) + ); + await user.click(screen.getByRole('button', { name: 'Continue' })); + + expect(readPairingAttribution()).toEqual({ + entrypoint: 'send-tab-toolbar-icon', + utm_source: 'firefox-browser', + }); + }); + + it('stashes the integration attribution when "Continue to sync" is clicked', async () => { + const user = userEvent.setup(); + await renderPair({ integration: attributionIntegration }); + + await user.click( + screen.getByLabelText(/I don’t have Firefox for mobile/) + ); + await user.click(screen.getByRole('button', { name: 'Continue' })); + await user.click( + screen.getByRole('button', { name: 'Continue to sync' }) + ); + + expect(readPairingAttribution()).toEqual({ + entrypoint: 'send-tab-toolbar-icon', + utm_source: 'firefox-browser', + }); + }); + + it('falls back to the URL when no integration is supplied', async () => { + const user = userEvent.setup(); + mockLocationSearch = '?entrypoint=fxa_app_menu'; + await renderPair(); + + await user.click( + screen.getByLabelText(/I already have Firefox for mobile/) + ); + await user.click(screen.getByRole('button', { name: 'Continue' })); + + expect(readPairingAttribution()).toEqual({ + entrypoint: 'fxa_app_menu', + }); + }); + + it('stashes nothing when the user only advances to the download screen', async () => { + const user = userEvent.setup(); + await renderPair({ integration: attributionIntegration }); + + await user.click( + screen.getByLabelText(/I don’t have Firefox for mobile/) + ); + await user.click(screen.getByRole('button', { name: 'Continue' })); + + expect(readPairingAttribution()).toEqual({}); + }); + + it('carries the attribution into the sync OAuth handoff URL', async () => { + const hardNavigateSpy = jest + .spyOn(ReactUtils, 'hardNavigate') + .mockImplementation(() => {}); + try { + jest.mocked(firefox.requestSignedInUser).mockResolvedValue(undefined); + jest.mocked(firefox.fxaOAuthFlowBegin).mockResolvedValueOnce({ + action: 'signin', + response_type: 'code', + access_type: 'offline', + scope: 'profile https://identity.mozilla.com/apps/oldsync', + client_id: 'cid-abc', + state: 'state-xyz', + code_challenge: 'cc', + code_challenge_method: 'S256', + }); + renderWithRouter(); + + await waitFor(() => expect(hardNavigateSpy).toHaveBeenCalled()); + const url = new URL( + hardNavigateSpy.mock.calls[0][0], + 'http://localhost' + ); + expect(url.searchParams.get('entrypoint')).toBe( + 'send-tab-toolbar-icon' + ); + expect(url.searchParams.get('utm_source')).toBe('firefox-browser'); + } finally { + hardNavigateSpy.mockRestore(); + } + }); + }); + describe('CMS theming', () => { it('renders the choice screen Continue button with CMS button color', async () => { await renderPair({ cmsInfo: MOCK_CMS_INFO }); diff --git a/packages/fxa-settings/src/pages/Pair/Index/index.tsx b/packages/fxa-settings/src/pages/Pair/Index/index.tsx index 30cbeed9446..4300e178095 100644 --- a/packages/fxa-settings/src/pages/Pair/Index/index.tsx +++ b/packages/fxa-settings/src/pages/Pair/Index/index.tsx @@ -2,7 +2,13 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Link, useLocation } from 'react-router'; import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery'; import { FtlMsg } from 'fxa-react/lib/utils'; @@ -31,6 +37,11 @@ import { buildPairingDownloadUrl, isSendTabEntrypoint, } from '../../../lib/utilities'; +import { + pickPairingAttribution, + pickPairingAttributionFromData, + stashPairingAttribution, +} from '../../../lib/pairing-attribution'; import type { PairOrigin } from '../../Signin/utils'; import type { SigninLocationState } from '../../Signin/interfaces'; import type { Integration } from '../../../models'; @@ -68,11 +79,7 @@ type PairProps = { }; export const viewName = 'pair'; -const Pair = ({ - error, - cmsInfo: cmsInfoProp, - integration, -}: PairProps) => { +const Pair = ({ error, cmsInfo: cmsInfoProp, integration }: PairProps) => { usePageViewEvent(viewName, REACT_ENTRYPOINT); const ftlMsgResolver = useFtlMsgResolver(); const localizedQRCodeLabel = ftlMsgResolver.getMsg( @@ -97,6 +104,17 @@ const Pair = ({ const choiceHeaderRef = useRef(null); const downloadHeaderRef = useRef(null); + // Attribution params to carry into the pairing flow (FXA-14132). Sourced from + // the integration so it matches what this page's own Glean events report; + // falls back to the URL when no integration is supplied (tests, stories). + const pairingAttribution = useMemo( + () => + integration?.data + ? pickPairingAttributionFromData(integration.data) + : pickPairingAttribution(location.search), + [integration, location.search] + ); + // Focus management after view transitions useEffect(() => { if (currentView === 'download') { @@ -148,7 +166,14 @@ const Pair = ({ .catch(() => null); if (cancelled) return; if (oauthParams) { - hardNavigate(`/?${buildSyncOAuthSearch(oauthParams)}`); + // buildSyncOAuthSearch emits OAuth params only, so the attribution + // params would be lost across the sign-in round trip and /pair would + // come back without an entrypoint (FXA-14132). + const search = buildSyncOAuthSearch(oauthParams); + for (const [key, value] of Object.entries(pairingAttribution)) { + search.set(key, value); + } + hardNavigate(`/?${search}`); return; } // WebChannel didn't reply; reveal the page so the user isn't stuck. @@ -158,6 +183,9 @@ const Pair = ({ return () => { cancelled = true; }; + // Run-once bootstrap: `pairingAttribution` and `navigateWithQuery` are + // intentionally captured at mount. Re-running on a new attribution value + // would re-ask the WebChannel and could double-navigate. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -184,8 +212,13 @@ const Pair = ({ // Tells Firefox to open about:preferences#sync and start pairing. const openPairPreferences = useCallback(() => { + // Firefox takes over from here and later opens a brand-new + // /oauth?…redirect_uri=…pair-auth-webchannel navigation that carries none of + // this page's attribution params (FXA-14132). Stash them so the approval page + // can restore them. + stashPairingAttribution(pairingAttribution); firefox.send(FirefoxCommand.PairPreferences, {}); - }, []); + }, [pairingAttribution]); const handleRadioChange = useCallback((value: MobileChoice) => { setSelectedRadio(value);