Files
supabase/packages/common/consented-url-cookie.test.ts
T
Aleksi ImmonenandSean Oliver 66e6cd1639 feat: capture Freebuff ad click ids (#50181)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature: ad attribution capture.

## What is the current behavior?

Freebuff ad clicks arrive on supabase.com with a signed click id in
`?bfcid=`. Nothing captures it, so those signups are unattributed.

## What is the new behavior?

This PR captures `bfcid` and writes it to a cookie that, in production,
is scoped so the management API receives it. The conversion is reported
server-side on profile creation, in a separate change tracked in
GROWTH-1217.

Start with `enforceConsentDecision` in
`packages/common/consented-url-cookie.ts`. It is the rule everything
else hangs off, and `consented-url-cookie.test.ts` covers the state
matrix.

Capture:

- `bfcid` is read on landing and held in `sessionStorage` until the
consent decision resolves. Memory alone loses it when someone navigates
before answering the banner.
- Once consent is granted it goes into a cookie. In production on
`*.supabase.com` that cookie is scoped to `domain=supabase.com`, and it
is host-only elsewhere. It is written only after consent, which is the
signal GROWTH-1217 relies on.
- Values are validated with `/^bfc_[A-Za-z0-9._-]{1,508}$/`, the
validator Freebuff publishes in their tag, so we never store a value
their tag would reject.
- `bfcid` is added to the first-touch attribution props, which feed
pageview telemetry and are already consent-gated.

Consent:

- `enforceConsentDecision` reduces the decision to two states. Undecided
and declined both clear the cookie, since neither has consent to point
at. They differ in the retained value: an undecided visitor may still
accept, so it waits for them.
- `clearConsentedUrlCookie` drops the cookie. `discardConsentedUrlValue`
also drops the retained value.
- A module-level valtio subscription registers on import, guarded on
`window` so it is inert during SSR.

`packages/common/consent-state.ts` gains a generic `isResolved` flag and
no vendor knowledge. A consumer acting on a decision needs to tell "not
decided yet" from "decided against", which `hasConsented` cannot express
alone. `applyPriorDecisionToSDK` now returns its promise chains, so its
signature becomes `void | Promise<void>` and initialization awaits
settlement before marking the decision resolved. Worth checking the call
sites.

## Additional context

160 tests pass in `packages/common`. Typecheck and Prettier are clean
locally on the changed files. CI is still running on the latest commit.

Unverified: the clearing paths are covered by unit tests only. The
consent SDK is short-circuited in local and preview builds, so they
cannot be exercised outside production. An end-to-end conversion
recorded by Freebuff is also unverified, since it needs the server-side
change deployed.

GROWTH-1216


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added consent-aware handling for Freebuff Ads click identifiers,
retaining valid URL values until consent is resolved and storing them in
a cookie after approval.
- Added automatic cleanup when consent is denied or withdrawn, while
preserving unrelated cookies.
- Added support for capturing the click identifier in first-touch
attribution data.
- **Bug Fixes**
- Improved consent initialization tracking so completion is reported
after successful or failed resolution.
- Added safeguards for restricted browser storage, cookies, and
server-rendered environments.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Sean Oliver <882952+seanoliver@users.noreply.github.com>
2026-09-11 12:14:08 -07:00

301 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
/// <reference types="vitest/jsdom" />
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { consentState } from './consent-state'
import {
clearConsentedUrlCookie,
createConsentedUrlCookieSync,
discardConsentedUrlValue,
} from './consented-url-cookie'
vi.hoisted(() => {
vi.stubEnv('NEXT_PUBLIC_VERCEL_ENV', 'production')
// The shared browser helpers also read this API during module initialization.
window.matchMedia = vi.fn().mockReturnValue({ matches: false })
})
const COOKIE_VALUE = 'bfc_test_1.Opaque_Value.signature'
const SECOND_COOKIE_VALUE = 'bfc_test_1.Second_Value.signature'
function openPage(url = `https://supabase.com/?bfcid=${COOKIE_VALUE}`) {
jsdom.reconfigure({ url })
return jsdom
}
beforeEach(() => {
// Undecided: the sync writes the cookie on an explicit `true` regardless of
// proxy state, so this default lets both paths be exercised.
consentState.isResolved = false
consentState.showConsentToast = false
consentState.hasConsented = false
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
jsdom.cookieJar.removeAllCookiesSync()
sessionStorage.clear()
})
describe('consented URL cookie', () => {
it('keeps the URL parameter in memory until consent, including after SPA navigation', () => {
const page = openPage()
const sync = createConsentedUrlCookieSync()
sync(false)
expect(document.cookie).toBe('')
window.history.replaceState(null, '', '/next-page')
sync(false)
expect(document.cookie).toBe('')
sync(true)
expect(page.cookieJar.getCookieStringSync('https://subdomain.supabase.com/endpoint')).toBe(
`bfcid=${COOKIE_VALUE}`
)
})
it('recovers the parameter after a page load that happened before consent', () => {
openPage()
createConsentedUrlCookieSync()(false)
expect(document.cookie).toBe('')
// A new page load builds a fresh closure and the URL no longer carries the parameter.
jsdom.reconfigure({ url: 'https://supabase.com/pricing' })
const afterNavigation = createConsentedUrlCookieSync()
afterNavigation(false)
afterNavigation(true)
expect(document.cookie).toBe(`bfcid=${COOKIE_VALUE}`)
})
it('drops the retained parameter once consent is denied', () => {
openPage()
createConsentedUrlCookieSync()(false)
discardConsentedUrlValue()
jsdom.reconfigure({ url: 'https://supabase.com/pricing' })
createConsentedUrlCookieSync()(true)
expect(document.cookie).toBe('')
})
it('does not throw when session storage is blocked', () => {
openPage()
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('Storage access blocked')
})
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('Storage access blocked')
})
const sync = createConsentedUrlCookieSync()
expect(() => sync(false)).not.toThrow()
expect(() => sync(true)).not.toThrow()
})
it('makes the unchanged cookie value available across subdomains and paths', () => {
const page = openPage()
createConsentedUrlCookieSync()(true)
const cookies = page.cookieJar.getCookiesSync('https://subdomain.supabase.com/endpoint')
expect(cookies).toHaveLength(1)
expect(cookies[0]).toMatchObject({
key: 'bfcid',
value: COOKIE_VALUE,
domain: 'supabase.com',
path: '/',
secure: true,
sameSite: 'lax',
maxAge: 2_592_000,
})
expect(page.cookieJar.getCookieStringSync('https://supabase.com/next-page')).toBe(
`bfcid=${COOKIE_VALUE}`
)
expect(page.cookieJar.getCookieStringSync('https://example.com/')).toBe('')
expect(page.cookieJar.getCookieStringSync('http://subdomain.supabase.com/endpoint')).toBe('')
})
it('preserves the cookie on later visits without refreshing its lifetime', () => {
const page = openPage()
createConsentedUrlCookieSync()(true)
const writeCookie = vi.spyOn(document, 'cookie', 'set')
createConsentedUrlCookieSync()(true)
window.history.replaceState(null, '', '/next-page')
createConsentedUrlCookieSync()(true)
expect(writeCookie).not.toHaveBeenCalled()
expect(page.cookieJar.getCookieStringSync('https://subdomain.supabase.com/')).toBe(
`bfcid=${COOKIE_VALUE}`
)
})
it('replaces the cookie when a new consented URL parameter arrives', () => {
openPage()
createConsentedUrlCookieSync()(true)
window.history.replaceState(null, '', `/?bfcid=${SECOND_COOKIE_VALUE}`)
createConsentedUrlCookieSync()(true)
expect(document.cookie).toBe(`bfcid=${SECOND_COOKIE_VALUE}`)
})
it('does not erase a returning visitor’s cookie while consent initializes', () => {
openPage()
createConsentedUrlCookieSync()(true)
window.history.replaceState(null, '', '/next-page')
const sync = createConsentedUrlCookieSync()
sync(false)
sync(true)
expect(document.cookie).toBe(`bfcid=${COOKIE_VALUE}`)
})
it('keeps the retained value while the banner is still showing', async () => {
openPage()
createConsentedUrlCookieSync()(false)
expect(sessionStorage.getItem('sb-bfcid-pending')).not.toBeNull()
consentState.isResolved = true
consentState.showConsentToast = true
await new Promise((resolve) => setTimeout(resolve, 10))
// An undecided visitor may still accept, so the value waits for them.
expect(sessionStorage.getItem('sb-bfcid-pending')).not.toBeNull()
})
it('clears an existing cookie when the decision resolves to no consent', async () => {
openPage()
createConsentedUrlCookieSync()(true)
document.cookie = 'unrelated=keep; Path=/'
consentState.isResolved = true
consentState.showConsentToast = true
await vi.waitFor(() => expect(document.cookie).toBe('unrelated=keep'))
})
// acceptAll sets hasConsented before the Usercentrics promise settles, so
// the cookie is written optimistically. Its failure path flips consent back
// and re-shows the banner, which has to take the cookie with it.
it('clears the cookie when an optimistic acceptance is rolled back', async () => {
openPage()
createConsentedUrlCookieSync()(true)
expect(document.cookie).toContain('bfcid=')
consentState.isResolved = true
consentState.hasConsented = false
consentState.showConsentToast = true
await vi.waitFor(() => expect(document.cookie).not.toContain('bfcid='))
})
it('discards the retained value once the visitor declines', async () => {
openPage()
createConsentedUrlCookieSync()(false)
expect(sessionStorage.getItem('sb-bfcid-pending')).not.toBeNull()
consentState.isResolved = true
await vi.waitFor(() => expect(sessionStorage.getItem('sb-bfcid-pending')).toBeNull())
expect(document.cookie).not.toContain('bfcid=')
})
it('does not clear anything before the consent decision resolves', async () => {
openPage()
createConsentedUrlCookieSync()(true)
consentState.isResolved = false
consentState.hasConsented = false
await new Promise((resolve) => setTimeout(resolve, 10))
expect(document.cookie).toContain('bfcid=')
})
it('clears host-only and shared cookies for an explicit denial', () => {
const page = openPage()
createConsentedUrlCookieSync()(true)
document.cookie = `bfcid=${SECOND_COOKIE_VALUE}; Path=/`
clearConsentedUrlCookie()
expect(document.cookie).toBe('')
expect(page.cookieJar.getCookieStringSync('https://subdomain.supabase.com/')).toBe('')
})
it.each([
'',
'?bfcid=',
'?bfcid=bfc_',
'?bfcid=invalid-value',
'?bfcid=bfc_unsafe%3Bvalue',
'?bfcid=bfc_unsafe%0A',
'?bfcid=bfc_unsafe%0D',
'?bfcid=bfc_unsafe+value',
`?bfcid=bfc_${'a'.repeat(509)}`,
`?bfcid=${COOKIE_VALUE}&bfcid=${SECOND_COOKIE_VALUE}`,
])('does not store missing, ambiguous or unsafe values: %s', (query) => {
openPage(`https://supabase.com/${query}`)
createConsentedUrlCookieSync()(true)
expect(document.cookie).toBe('')
})
it('preserves the full 512-character identifier without truncation', () => {
// Freebuff validates with /^bfc_[A-Za-z0-9._-]{1,508}$/, so anything
// longer would be stored here and then rejected by them.
const cookieValue = `bfc_${'a'.repeat(508)}`
openPage(`https://supabase.com/?bfcid=${cookieValue}`)
createConsentedUrlCookieSync()(true)
expect(document.cookie).toBe(`bfcid=${cookieValue}`)
})
it.each(['http://localhost:3000', 'https://preview.example.com', 'https://supabase.com.example'])(
'uses host-only storage on a development or preview origin: %s',
(origin) => {
const page = openPage(`${origin}/?bfcid=${COOKIE_VALUE}`)
createConsentedUrlCookieSync()(true)
expect(document.cookie).toBe(`bfcid=${COOKIE_VALUE}`)
expect(page.cookieJar.getCookiesSync(origin)[0].hostOnly).toBe(true)
expect(page.cookieJar.getCookieStringSync('https://subdomain.supabase.com/')).toBe('')
}
)
it('follows the shared host-only policy for preview deployments on a Supabase subdomain', async () => {
const page = openPage(`https://preview.supabase.com/?bfcid=${COOKIE_VALUE}`)
vi.stubEnv('NEXT_PUBLIC_VERCEL_ENV', 'preview')
vi.resetModules()
try {
const { createConsentedUrlCookieSync: createPreviewCookieSync } =
await import('./consented-url-cookie')
createPreviewCookieSync()(true)
expect(document.cookie).toBe(`bfcid=${COOKIE_VALUE}`)
expect(page.cookieJar.getCookiesSync('https://preview.supabase.com/')[0].hostOnly).toBe(true)
expect(page.cookieJar.getCookieStringSync('https://subdomain.supabase.com/')).toBe('')
} finally {
vi.stubEnv('NEXT_PUBLIC_VERCEL_ENV', 'production')
}
})
it('does not throw during cookie setup or consent changes when storage is blocked', () => {
openPage()
vi.spyOn(document, 'cookie', 'get').mockImplementation(() => {
throw new Error('Cookie access blocked')
})
vi.spyOn(document, 'cookie', 'set').mockImplementation(() => {
throw new Error('Cookie access blocked')
})
const sync = createConsentedUrlCookieSync()
expect(() => sync(true)).not.toThrow()
expect(() => sync(false)).not.toThrow()
expect(() => clearConsentedUrlCookie()).not.toThrow()
})
it('does not access browser globals during server rendering', () => {
vi.stubGlobal('window', undefined)
vi.stubGlobal('document', undefined)
expect(() => createConsentedUrlCookieSync()(true)).not.toThrow()
expect(() => clearConsentedUrlCookie()).not.toThrow()
})
})