Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions apps/sim/app/_shell/consent/consent-banner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockUseConsentManager, mockUseHeadlessConsentUI } = vi.hoisted(() => ({
mockUseConsentManager: vi.fn(),
mockUseHeadlessConsentUI: vi.fn(),
}))

vi.mock('@c15t/nextjs/headless', () => ({
useConsentManager: mockUseConsentManager,
useHeadlessConsentUI: mockUseHeadlessConsentUI,
}))

vi.mock('@/app/_shell/consent/consent-preferences', () => ({
CONSENT_LINK_CLASS: 'link',
ConsentPreferences: () => <span data-testid='preferences' />,
}))

import { ConsentBanner } from '@/app/_shell/consent/consent-banner'

let root: Root | null = null

function render(): HTMLDivElement {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => root?.render(<ConsentBanner />))
return container
}

function isBannerShown(container: HTMLDivElement): boolean {
return container.querySelector('section[aria-label="Cookie preferences"]') !== null
}

beforeEach(() => {
mockUseHeadlessConsentUI.mockReturnValue({
banner: { isVisible: true, allowedActions: ['accept', 'reject', 'customize'] },
dialog: { isVisible: false, allowedActions: [] },
openDialog: vi.fn(),
performAction: vi.fn(),
saveCustomPreferences: vi.fn(),
})
})

afterEach(() => {
act(() => root?.unmount())
root = null
vi.clearAllMocks()
})

describe('ConsentBanner', () => {
it.each(['backend', 'backend-cache-hit', 'ssr'])(
'asks for consent when the policy came from %s',
(initDataSource) => {
mockUseConsentManager.mockReturnValue({ initDataSource })

expect(isBannerShown(render())).toBe(true)
}
)

it('still renders a dialog the visitor opened, so the published control works', () => {
mockUseHeadlessConsentUI.mockReturnValue({
banner: { isVisible: false, allowedActions: [] },
dialog: { isVisible: true, allowedActions: ['accept', 'reject', 'customize'] },
openDialog: vi.fn(),
performAction: vi.fn(),
saveCustomPreferences: vi.fn(),
})
mockUseConsentManager.mockReturnValue({ initDataSource: 'offline-fallback' })

expect(isBannerShown(render())).toBe(true)
})

it('asks nothing when the policy lookup fell back', () => {
// A bot challenge on the third-party `/init` makes the runtime substitute a
// generic opt-in policy, which would otherwise re-prompt visitors who had
// already consented under the real one.
mockUseConsentManager.mockReturnValue({ initDataSource: 'offline-fallback' })

expect(isBannerShown(render())).toBe(false)
})
})
21 changes: 19 additions & 2 deletions apps/sim/app/_shell/consent/consent-banner.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useHeadlessConsentUI } from '@c15t/nextjs/headless'
import { useConsentManager, useHeadlessConsentUI } from '@c15t/nextjs/headless'
import { Chip } from '@sim/emcn'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import Link from 'next/link'
Expand Down Expand Up @@ -29,20 +29,37 @@ const CATEGORIES_OPEN = { height: 'auto', opacity: 1 } as const
* the light layer on `<html>` through `ThemeProvider`'s forced theme, or is a
* themed app page where inheriting is what should happen — the card no longer
* decides for itself.
*
* Nothing is asked *unprompted* when the policy lookup failed. `/init` answers
* from a third-party origin, and when that origin refuses the request — a bot
* challenge returns `403` with an HTML body — the runtime substitutes a generic
* opt-in policy rather than surfacing the failure. Volunteering a banner from
* it asks a question the visitor's jurisdiction may not require, and asks it of
* people who already answered, because the substituted policy's fingerprint
* never matches the one their stored consent was recorded under. The next load
* that reaches the real policy asks properly if it still needs to.
*
* A dialog the visitor opened themselves still renders, fallback or not: the
* Cookie Policy promises the choice can be changed at any time, and a control
* that silently does nothing breaks that promise. A choice saved during a
* fallback is recorded against the substituted policy and will be asked for
* again once the real one resolves, which is the lesser of the two failures.
*/
export function ConsentBanner() {
const { banner, dialog, openDialog, performAction, saveCustomPreferences } =
useHeadlessConsentUI()
const { initDataSource } = useConsentManager()
const prefersReducedMotion = useReducedMotion()

const isPolicyResolved = initDataSource !== 'offline-fallback'
const isExpanded = dialog.isVisible
const surfaceName = isExpanded ? 'dialog' : 'banner'
const { allowedActions } = isExpanded ? dialog : banner
const enterOffset = prefersReducedMotion ? 0 : 8

return (
<AnimatePresence>
{(banner.isVisible || dialog.isVisible) && (
{((isPolicyResolved && banner.isVisible) || dialog.isVisible) && (
<motion.section
aria-label='Cookie preferences'
initial={{ opacity: 0, y: enterOffset }}
Expand Down
13 changes: 13 additions & 0 deletions apps/sim/lib/consent/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@
* Sim's consent instance. Public by construction — the browser calls it
* directly, so it is a client-visible origin like the GTM and GA container IDs
* in the root layout, not a credential.
*
* The browser must keep calling it directly. c15t documents a same-origin
* rewrite (`/api/c15t/:path*`) as an optimization, and it would also sidestep
* the bot challenge this origin sometimes answers with — but it resolves the
* jurisdiction from the address the request arrives from, and proxying makes
* every visitor arrive from our servers. Measured against the live instance:
* `x-forwarded-for`, `x-real-ip`, `true-client-ip`, `cf-connecting-ip` and
* `x-vercel-ip-country` are all ignored, and only c15t's own `x-c15t-country`
* override is honored. Sim has no edge that supplies a country header for us to
* forward into it, so behind a proxy every visitor would resolve to our region
* and no one in the EU would be asked for consent at all. The same dependency
* rules out the SSR prefetch, which reads those headers through
* `extractRelevantHeaders`. Revisit only alongside an edge that provides geo.
*/
export const CONSENT_BACKEND_URL = 'https://sim-sim.inth.app'

Expand Down
Loading