mirror of
https://github.com/supabase/supabase.git
synced 2026-05-07 23:19:23 +08:00
## Problem
Cookie banner keeps re-prompting GDPR users who denied consent or made a
partial opt-out via Privacy Settings — they can't get rid of it.
Reported by Christian Gedde-Dahl (Front SU-362240, mygame.no) and a
Supabase support engineer independently.
The March fix for FE-2648 handled the accept case — users got their
banner dismissal stomped when GTM's Usercentrics integration migrated
localStorage from `uc_settings` to `ucData`/`ucString`. But that fix
only recognized uniformly-accepted `ucData`, so any other shape
(deny-all, essentials-plus-some-tracking, partial opt-out via the
Privacy Settings modal) fell through and was treated as "no prior
decision." Banner re-prompts on every page load.
Christian's and his colleague's `ucData.consent.services` showed 13
services accepted (essentials + functional) and 4 tracking services
denied — the shape you get from toggling off the Marketing category in
Privacy Settings. Our detection ignored it.
## Changes
- `detectPriorConsent` now returns a discriminated union — `null`, `{
kind: 'uniform-accept' }`, or `{ kind: 'decisions'; decisions }` — so we
restore per-service state faithfully instead of flattening to deny-all.
- Parse `uc_settings` for the fast cross-app nav case. The old fallback
treated `uc_user_interaction === "true"` as uniform-accept, which
silently upgraded deny users (GDPR violation waiting to happen). Now the
flag is just a gate confirming the user actually interacted, and we read
real decisions from `uc_settings.services[]`.
- Extracted the post-init orchestration from `initUserCentrics` into
exported `applyPriorDecisionToSDK(UC, initialUIValues, priorDecision)`
so it's unit-testable without mocking the dynamic SDK import.
- Added a coverage cross-check: if the Usercentrics ruleset has a
non-essential service that isn't in the user's stored decisions, force a
re-prompt rather than silently defaulting the new service. Essentials
are skipped because the SDK forces them on regardless.
- Everything fails closed on partial `ucData` / `uc_settings`
corruption. Cherry-picking the valid subset would bias toward
over-consent, which is the worst-direction bias in this domain.
## Testing
27 unit tests covering `detectPriorConsent` parsing (both `ucData` and
`uc_settings` paths, fail-closed on malformed or partially-corrupt
blobs, combined scenarios) and `applyPriorDecisionToSDK` orchestration
(uniform accept, decisions with full coverage, uncovered-non-essential
compliance guard, essentials-only negative control, null fallthrough,
SDK-already-consented passthrough).
Can't fully repro on staging — CSP blocks GTM on preview, so the
`ucData` migration never fires. Same limitation as the original FE-2648
fix. Will verify in production post-merge by asking Christian to reload
and watching support ticket volume.
Reviewed twice by Codex with a full iteration between passes; final pass
found no functional blockers.
GROWTH-790
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Fail-closed validation for stored consent data: malformed, partial, or
missing entries now yield null and avoid unsafe restoration.
* Improved precedence and fallback so corrupt prior data won’t
incorrectly restore consent.
* **Refactor**
* Consent detection now returns richer prior-decision results (uniform
accept, per-service decisions, or null).
* Applying prior decisions to the SDK uses stricter coverage checks
before restoring per-service consent.
* **Tests**
* Expanded tests covering varied stored-consent shapes, gating rules,
precedence, recovery, and SDK application behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
451 lines
16 KiB
TypeScript
451 lines
16 KiB
TypeScript
// @vitest-environment jsdom
|
|
import type { UserDecision } from '@usercentrics/cmp-browser-sdk'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { applyPriorDecisionToSDK, consentState, detectPriorConsent } from './consent-state'
|
|
|
|
// jsdom's localStorage can be flaky in vitest, so ensure it's available
|
|
const storage = new Map<string, string>()
|
|
const mockLocalStorage = {
|
|
getItem: (key: string) => storage.get(key) ?? null,
|
|
setItem: (key: string, value: string) => storage.set(key, value),
|
|
removeItem: (key: string) => storage.delete(key),
|
|
clear: () => storage.clear(),
|
|
get length() {
|
|
return storage.size
|
|
},
|
|
key: (index: number) => Array.from(storage.keys())[index] ?? null,
|
|
}
|
|
|
|
beforeEach(() => {
|
|
storage.clear()
|
|
Object.defineProperty(globalThis, 'localStorage', {
|
|
value: mockLocalStorage,
|
|
writable: true,
|
|
configurable: true,
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
storage.clear()
|
|
})
|
|
|
|
describe('detectPriorConsent', () => {
|
|
describe('scenario 1: GTM compressed format (ucData)', () => {
|
|
it('returns uniform-accept when every service has consent: true', () => {
|
|
storage.set(
|
|
'ucData',
|
|
JSON.stringify({
|
|
gcm: { adStorage: 'granted', analyticsStorage: 'granted' },
|
|
consent: {
|
|
services: {
|
|
svc1: { name: 'Google Analytics', consent: true },
|
|
svc2: { name: 'Stripe', consent: true },
|
|
svc3: { name: 'Sentry', consent: true },
|
|
},
|
|
},
|
|
})
|
|
)
|
|
|
|
expect(detectPriorConsent()).toEqual({ kind: 'uniform-accept' })
|
|
})
|
|
|
|
// Real customer data shape from GROWTH-790: essentials and functional
|
|
// services accepted, marketing/tracking services denied. This is what
|
|
// Opt out or a Privacy Settings toggle of the Marketing category produces
|
|
// in production — essentials are locked on and cannot actually be denied.
|
|
it('returns per-service decisions when ucData has a mixed consent state (GROWTH-790)', () => {
|
|
storage.set(
|
|
'ucData',
|
|
JSON.stringify({
|
|
gcm: {
|
|
adsDataRedaction: true,
|
|
adStorage: 'denied',
|
|
adPersonalization: 'denied',
|
|
adUserData: 'denied',
|
|
analyticsStorage: 'denied',
|
|
},
|
|
consent: {
|
|
services: {
|
|
J39GyuWQq: { name: 'Amazon Web Services', consent: true },
|
|
HkIVcNiuoZX: { name: 'Cloudflare', consent: true },
|
|
H1PKqNodoWQ: { name: 'Google AJAX', consent: true },
|
|
HkPBYFofN: { name: 'Google Fonts', consent: true },
|
|
QjO6LaiOd: { name: 'hCaptcha', consent: true },
|
|
'F-REmjGq7': { name: 'JSDelivr', consent: true },
|
|
Hko_qNsui_Q: { name: 'reCAPTCHA', consent: true },
|
|
rH1vNPCFR: { name: 'Sentry', consent: true },
|
|
ry3w9Vo_oZ7: { name: 'Stripe', consent: true },
|
|
BJTzqNi_i_m: { name: 'Twitter Plugin', consent: true },
|
|
HLap0udLC: { name: 'Twitter Syndication', consent: true },
|
|
H1Vl5NidjWX: { name: 'Usercentrics Consent Management Platform', consent: true },
|
|
BJz7qNsdj_7: { name: 'YouTube Video', consent: true },
|
|
S1_9Vsuj_Q: { name: 'Google Ads', consent: false },
|
|
HkocEodjb7: { name: 'Google Analytics', consent: false },
|
|
BJ59EidsWQ: { name: 'Google Tag Manager', consent: false },
|
|
nV4hGUA8RQK5Ez: { name: 'Supabase Event Tracking', consent: false },
|
|
},
|
|
},
|
|
})
|
|
)
|
|
|
|
const result = detectPriorConsent()
|
|
if (result === null || result.kind !== 'decisions') {
|
|
throw new Error(`expected decisions result, got ${JSON.stringify(result)}`)
|
|
}
|
|
expect(result.decisions).toHaveLength(17)
|
|
// Essentials / functional services preserved as accepted
|
|
expect(result.decisions).toContainEqual({ serviceId: 'J39GyuWQq', status: true })
|
|
expect(result.decisions).toContainEqual({ serviceId: 'rH1vNPCFR', status: true })
|
|
// Tracking services preserved as denied
|
|
expect(result.decisions).toContainEqual({ serviceId: 'S1_9Vsuj_Q', status: false })
|
|
expect(result.decisions).toContainEqual({ serviceId: 'HkocEodjb7', status: false })
|
|
expect(result.decisions).toContainEqual({ serviceId: 'BJ59EidsWQ', status: false })
|
|
expect(result.decisions).toContainEqual({ serviceId: 'nV4hGUA8RQK5Ez', status: false })
|
|
})
|
|
|
|
it('returns per-service decisions (all false) when every service was denied', () => {
|
|
storage.set(
|
|
'ucData',
|
|
JSON.stringify({
|
|
consent: {
|
|
services: {
|
|
svc1: { name: 'Google Analytics', consent: false },
|
|
svc2: { name: 'Stripe', consent: false },
|
|
},
|
|
},
|
|
})
|
|
)
|
|
|
|
expect(detectPriorConsent()).toEqual({
|
|
kind: 'decisions',
|
|
decisions: [
|
|
{ serviceId: 'svc1', status: false },
|
|
{ serviceId: 'svc2', status: false },
|
|
],
|
|
})
|
|
})
|
|
|
|
it('returns null when services object is empty', () => {
|
|
storage.set('ucData', JSON.stringify({ consent: { services: {} } }))
|
|
expect(detectPriorConsent()).toBeNull()
|
|
})
|
|
|
|
it('returns null when ucData is malformed JSON', () => {
|
|
storage.set('ucData', 'not-json')
|
|
expect(detectPriorConsent()).toBeNull()
|
|
})
|
|
|
|
it('returns null when ucData has no consent.services', () => {
|
|
storage.set('ucData', JSON.stringify({ gcm: {} }))
|
|
expect(detectPriorConsent()).toBeNull()
|
|
})
|
|
|
|
it('returns null when every service value is non-object', () => {
|
|
storage.set('ucData', JSON.stringify({ consent: { services: { svc1: 'not-an-object' } } }))
|
|
expect(detectPriorConsent()).toBeNull()
|
|
})
|
|
|
|
// Partial parse failures must fail closed. Cherry-picking the valid
|
|
// subset and calling it uniform-accept would let corrupted third-party
|
|
// storage silently upgrade a user's consent — the worst-direction bias
|
|
// in this domain.
|
|
it('returns null when any entry fails schema (fails closed on partial corruption)', () => {
|
|
storage.set(
|
|
'ucData',
|
|
JSON.stringify({
|
|
consent: {
|
|
services: {
|
|
svc1: 'not-an-object',
|
|
svc2: { name: 'Valid', consent: true },
|
|
},
|
|
},
|
|
})
|
|
)
|
|
expect(detectPriorConsent()).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('scenario 2: fast cross-app navigation (uc_settings gated by uc_user_interaction)', () => {
|
|
const buildUcSettings = (services: Array<{ id: string; status: boolean }>) =>
|
|
JSON.stringify({
|
|
controllerId: 'test-controller',
|
|
id: 'test-settings',
|
|
language: 'en',
|
|
services: services.map((s) => ({
|
|
id: s.id,
|
|
status: s.status,
|
|
processorId: 'test-processor',
|
|
history: [],
|
|
version: '1.0.0',
|
|
})),
|
|
version: '1.0.0',
|
|
})
|
|
|
|
it('returns uniform-accept when uc_settings has every service accepted', () => {
|
|
storage.set('uc_user_interaction', 'true')
|
|
storage.set(
|
|
'uc_settings',
|
|
buildUcSettings([
|
|
{ id: 'svc1', status: true },
|
|
{ id: 'svc2', status: true },
|
|
])
|
|
)
|
|
expect(detectPriorConsent()).toEqual({ kind: 'uniform-accept' })
|
|
})
|
|
|
|
it('returns per-service decisions when uc_settings has a mixed state', () => {
|
|
storage.set('uc_user_interaction', 'true')
|
|
storage.set(
|
|
'uc_settings',
|
|
buildUcSettings([
|
|
{ id: 'essential1', status: true },
|
|
{ id: 'tracking1', status: false },
|
|
{ id: 'tracking2', status: false },
|
|
])
|
|
)
|
|
const result = detectPriorConsent()
|
|
if (result === null || result.kind !== 'decisions') {
|
|
throw new Error(`expected decisions result, got ${JSON.stringify(result)}`)
|
|
}
|
|
expect(result.decisions).toHaveLength(3)
|
|
expect(result.decisions).toContainEqual({ serviceId: 'essential1', status: true })
|
|
expect(result.decisions).toContainEqual({ serviceId: 'tracking1', status: false })
|
|
expect(result.decisions).toContainEqual({ serviceId: 'tracking2', status: false })
|
|
})
|
|
|
|
it('returns null when uc_user_interaction is "true" but uc_settings is absent', () => {
|
|
storage.set('uc_user_interaction', 'true')
|
|
expect(detectPriorConsent()).toBeNull()
|
|
})
|
|
|
|
it('returns null when uc_settings is malformed JSON', () => {
|
|
storage.set('uc_user_interaction', 'true')
|
|
storage.set('uc_settings', 'not-json')
|
|
expect(detectPriorConsent()).toBeNull()
|
|
})
|
|
|
|
it('returns null when uc_settings services array is empty', () => {
|
|
storage.set('uc_user_interaction', 'true')
|
|
storage.set('uc_settings', buildUcSettings([]))
|
|
expect(detectPriorConsent()).toBeNull()
|
|
})
|
|
|
|
it('returns null when any uc_settings service entry fails schema', () => {
|
|
storage.set('uc_user_interaction', 'true')
|
|
storage.set(
|
|
'uc_settings',
|
|
JSON.stringify({
|
|
services: [
|
|
{ id: 'svc1', status: true, processorId: 'p', history: [], version: '1' },
|
|
{ id: 'svc2', status: 'not-a-boolean', processorId: 'p', history: [], version: '1' },
|
|
],
|
|
})
|
|
)
|
|
expect(detectPriorConsent()).toBeNull()
|
|
})
|
|
|
|
it('returns null when uc_user_interaction is "false" (even if uc_settings is valid)', () => {
|
|
storage.set('uc_user_interaction', 'false')
|
|
storage.set('uc_settings', buildUcSettings([{ id: 'svc1', status: true }]))
|
|
expect(detectPriorConsent()).toBeNull()
|
|
})
|
|
|
|
it('returns null when uc_user_interaction is absent', () => {
|
|
storage.set('uc_settings', buildUcSettings([{ id: 'svc1', status: true }]))
|
|
expect(detectPriorConsent()).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('combined scenarios', () => {
|
|
it('returns uniform-accept when ucData is fully accepted (ignoring uc_user_interaction/uc_settings)', () => {
|
|
storage.set(
|
|
'ucData',
|
|
JSON.stringify({
|
|
consent: { services: { svc1: { name: 'GA', consent: true } } },
|
|
})
|
|
)
|
|
storage.set('uc_user_interaction', 'true')
|
|
expect(detectPriorConsent()).toEqual({ kind: 'uniform-accept' })
|
|
})
|
|
|
|
it('prefers ucData decisions over uc_settings when both exist', () => {
|
|
storage.set(
|
|
'ucData',
|
|
JSON.stringify({
|
|
consent: { services: { svc1: { name: 'GA', consent: false } } },
|
|
})
|
|
)
|
|
storage.set('uc_user_interaction', 'true')
|
|
storage.set(
|
|
'uc_settings',
|
|
JSON.stringify({
|
|
services: [{ id: 'svc1', status: true, processorId: 'p', history: [], version: '1' }],
|
|
})
|
|
)
|
|
expect(detectPriorConsent()).toEqual({
|
|
kind: 'decisions',
|
|
decisions: [{ serviceId: 'svc1', status: false }],
|
|
})
|
|
})
|
|
|
|
it('falls back to uc_settings when ucData is corrupt (scenario 1 fails closed, scenario 2 recovers)', () => {
|
|
storage.set('ucData', JSON.stringify({ consent: { services: { svc1: 'corrupt' } } }))
|
|
storage.set('uc_user_interaction', 'true')
|
|
storage.set(
|
|
'uc_settings',
|
|
JSON.stringify({
|
|
services: [{ id: 'svc1', status: false, processorId: 'p', history: [], version: '1' }],
|
|
})
|
|
)
|
|
expect(detectPriorConsent()).toEqual({
|
|
kind: 'decisions',
|
|
decisions: [{ serviceId: 'svc1', status: false }],
|
|
})
|
|
})
|
|
|
|
it('returns null when localStorage is completely empty', () => {
|
|
expect(detectPriorConsent()).toBeNull()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('applyPriorDecisionToSDK', () => {
|
|
type MockService = { id: string; isEssential: boolean }
|
|
|
|
const makeMockUC = (
|
|
opts: {
|
|
services?: MockService[]
|
|
areAllAccepted?: boolean
|
|
onAcceptAll?: () => Promise<void>
|
|
onUpdateServices?: (decisions: UserDecision[]) => Promise<void>
|
|
} = {}
|
|
) => {
|
|
const services = opts.services ?? []
|
|
return {
|
|
acceptAllServices: vi.fn(opts.onAcceptAll ?? (() => Promise.resolve())),
|
|
denyAllServices: vi.fn(() => Promise.resolve()),
|
|
updateServices: vi.fn(opts.onUpdateServices ?? (() => Promise.resolve())),
|
|
getCategoriesBaseInfo: vi.fn(() => []),
|
|
getServicesBaseInfo: vi.fn(() => services),
|
|
areAllConsentsAccepted: vi.fn(() => opts.areAllAccepted ?? false),
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
consentState.UC = null
|
|
consentState.categories = null
|
|
consentState.showConsentToast = false
|
|
consentState.hasConsented = false
|
|
})
|
|
|
|
it('uniform-accept: calls acceptAllServices, sets hasConsented, suppresses banner', () => {
|
|
const UC = makeMockUC()
|
|
applyPriorDecisionToSDK(UC as never, { initialLayer: 0 }, { kind: 'uniform-accept' })
|
|
|
|
expect(UC.acceptAllServices).toHaveBeenCalledOnce()
|
|
expect(UC.updateServices).not.toHaveBeenCalled()
|
|
expect(consentState.hasConsented).toBe(true)
|
|
expect(consentState.showConsentToast).toBe(false)
|
|
})
|
|
|
|
it('decisions with full coverage: calls updateServices with stored decisions, suppresses banner', () => {
|
|
const UC = makeMockUC({
|
|
services: [
|
|
{ id: 'essential1', isEssential: true },
|
|
{ id: 'tracking1', isEssential: false },
|
|
{ id: 'tracking2', isEssential: false },
|
|
],
|
|
})
|
|
const decisions: UserDecision[] = [
|
|
{ serviceId: 'tracking1', status: true },
|
|
{ serviceId: 'tracking2', status: false },
|
|
]
|
|
|
|
applyPriorDecisionToSDK(UC as never, { initialLayer: 0 }, { kind: 'decisions', decisions })
|
|
|
|
expect(UC.updateServices).toHaveBeenCalledWith(decisions)
|
|
expect(UC.acceptAllServices).not.toHaveBeenCalled()
|
|
expect(consentState.showConsentToast).toBe(false)
|
|
})
|
|
|
|
// GROWTH-790 compliance guard: when the ruleset adds a new non-essential
|
|
// service after the user's stored decisions were written, we must NOT
|
|
// silently restore — the user has never seen this service and can't have
|
|
// consented to it. Show the banner instead.
|
|
it('decisions with uncovered non-essential service: shows banner, does not mutate SDK', () => {
|
|
const UC = makeMockUC({
|
|
services: [
|
|
{ id: 'tracking1', isEssential: false },
|
|
{ id: 'tracking_new', isEssential: false }, // in ruleset, not in decisions
|
|
],
|
|
})
|
|
|
|
applyPriorDecisionToSDK(
|
|
UC as never,
|
|
{ initialLayer: 0 },
|
|
{ kind: 'decisions', decisions: [{ serviceId: 'tracking1', status: false }] }
|
|
)
|
|
|
|
expect(UC.updateServices).not.toHaveBeenCalled()
|
|
expect(UC.acceptAllServices).not.toHaveBeenCalled()
|
|
expect(consentState.showConsentToast).toBe(true)
|
|
})
|
|
|
|
// Essentials are SDK-forced-on regardless of user decision, so a new
|
|
// essential service appearing since the stored decisions were written
|
|
// should not trigger a re-prompt — the user has nothing meaningful to
|
|
// decide about it.
|
|
it('decisions covering only non-essentials: still restores (essentials ignored in coverage check)', () => {
|
|
const UC = makeMockUC({
|
|
services: [
|
|
{ id: 'essential_new', isEssential: true }, // not in decisions, but essential
|
|
{ id: 'tracking1', isEssential: false },
|
|
],
|
|
})
|
|
|
|
applyPriorDecisionToSDK(
|
|
UC as never,
|
|
{ initialLayer: 0 },
|
|
{ kind: 'decisions', decisions: [{ serviceId: 'tracking1', status: false }] }
|
|
)
|
|
|
|
expect(UC.updateServices).toHaveBeenCalledOnce()
|
|
expect(consentState.showConsentToast).toBe(false)
|
|
})
|
|
|
|
it('null prior decision: shows banner at default', () => {
|
|
const UC = makeMockUC()
|
|
applyPriorDecisionToSDK(UC as never, { initialLayer: 0 }, null)
|
|
|
|
expect(UC.updateServices).not.toHaveBeenCalled()
|
|
expect(UC.acceptAllServices).not.toHaveBeenCalled()
|
|
expect(consentState.showConsentToast).toBe(true)
|
|
})
|
|
|
|
it('SDK not requesting first-layer banner: no restore attempted even if priorDecision exists', () => {
|
|
const UC = makeMockUC({ areAllAccepted: true })
|
|
applyPriorDecisionToSDK(UC as never, { initialLayer: 1 }, { kind: 'uniform-accept' })
|
|
|
|
expect(UC.acceptAllServices).not.toHaveBeenCalled()
|
|
expect(UC.updateServices).not.toHaveBeenCalled()
|
|
expect(consentState.hasConsented).toBe(true)
|
|
expect(consentState.showConsentToast).toBe(false)
|
|
})
|
|
|
|
it('SDK already considers user consented: passes through, no restore', () => {
|
|
const UC = makeMockUC({ areAllAccepted: true })
|
|
applyPriorDecisionToSDK(
|
|
UC as never,
|
|
{ initialLayer: 0 },
|
|
{
|
|
kind: 'decisions',
|
|
decisions: [{ serviceId: 'svc1', status: false }],
|
|
}
|
|
)
|
|
|
|
expect(UC.updateServices).not.toHaveBeenCalled()
|
|
expect(consentState.hasConsented).toBe(true)
|
|
})
|
|
})
|