diff --git a/packages/manager/apps/container/src/cmp/cmp.constants.ts b/packages/manager/apps/container/src/cmp/cmp.constants.ts new file mode 100644 index 000000000000..ad125d24bc9f --- /dev/null +++ b/packages/manager/apps/container/src/cmp/cmp.constants.ts @@ -0,0 +1,39 @@ +/** + * Stable-name CMP loader URLs (two-stage bootstrap: the loader fetches + * `version.json` then injects the versioned bundle), provided at build time + * through the `VITE_CMP_LOADER_URL` (production) and + * `VITE_CMP_LOADER_URL_PREPROD` (everything else) environment variables. + * Both values ship in the same build artifact — the same dist is deployed to + * every environment — and the effective one is selected at runtime from the + * hostname. When the selected variable is not provided, the CMP is considered + * unavailable and the legacy consent modal takes over — a safe, + * unchanged-behavior default. + */ +export const CMP_LOADER_URLS: Record< + 'production' | 'preproduction', + string +> = { + production: import.meta.env?.VITE_CMP_LOADER_URL ?? '', + preproduction: import.meta.env?.VITE_CMP_LOADER_URL_PREPROD ?? '', +}; + +/** + * Production container hostnames (mirrors HOSTNAME_REGIONS in + * @ovh-ux/manager-config). Anything else — localhost, CI, lab envs — declares + * `preproduction` to the CMP (fail-closed: a misconfigured page never writes + * consent to the production API). + */ +export const CMP_PROD_HOSTNAME_RE = /^manager\.(eu|ca|us)\.ovhcloud\.com$/; + +/** + * CustomEvent dispatched (synchronously) on `window` by the CMP bundle once + * `window.__cmp` is registered and the page-load consent check has run. + */ +export const CMP_READY_EVENT = 'cmp:ready'; + +/** + * Fail-safe delay for `cmp:ready`: if the loader never runs (URL unavailable, + * blocked, crashed) the event never fires. Past this delay the CMP is flagged + * as failed and the caller falls back to the legacy consent modal. + */ +export const CMP_READY_TIMEOUT_MS = 10000; diff --git a/packages/manager/apps/container/src/cmp/cmp.spec.ts b/packages/manager/apps/container/src/cmp/cmp.spec.ts new file mode 100644 index 000000000000..db7c6f68aa84 --- /dev/null +++ b/packages/manager/apps/container/src/cmp/cmp.spec.ts @@ -0,0 +1,216 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createCmp } from './cmp'; +import { CMP_READY_EVENT, CMP_READY_TIMEOUT_MS } from './cmp.constants'; + +const LOADER_URLS = { + production: 'https://cmp.example/prod/cmp.iife.js', + preproduction: 'https://cmp.example/preprod/cmp.iife.js', +}; + +type CmpTestWindow = Window & { + __cmp?: (command: string, ...args: unknown[]) => unknown; + __cmpConfig?: Record; +}; + +const testWindow = (window as unknown) as CmpTestWindow; + +const injectScript = vi.fn(); + +const makeCmp = () => createCmp({ injectScript, loaderUrls: LOADER_URLS }); + +const setHostname = (hostname: string) => { + Object.defineProperty(window, 'location', { + value: { ...window.location, hostname }, + writable: true, + configurable: true, + }); +}; + +/** Simulates the CMP bundle coming up: registers __cmp then fires cmp:ready. */ +const simulateCmpReady = (api = vi.fn()) => { + testWindow.__cmp = api; + window.dispatchEvent(new CustomEvent(CMP_READY_EVENT)); + return api; +}; + +describe('cmp facade', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + setHostname('manager.eu.ovhcloud.com'); + delete testWindow.__cmp; + delete testWindow.__cmpConfig; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('load', () => { + it('declares __cmpConfig and picks the production loader on a production hostname', () => { + let configAtInjection: Record | undefined; + injectScript.mockImplementation(() => { + configAtInjection = testWindow.__cmpConfig + ? { ...testWindow.__cmpConfig } + : undefined; + }); + + makeCmp().load({ locale: 'fr_FR', region: 'EU' }); + + expect(injectScript).toHaveBeenCalledTimes(1); + expect(injectScript.mock.calls[0]?.[0]).toBe(LOADER_URLS.production); + expect(configAtInjection).toEqual({ + locale: 'fr-FR', + region: 'EU', + environment: 'production', + scripts: [], + }); + }); + + it.each(['localhost', 'manager.lab.ovh.dev'])( + 'declares preproduction and picks the preprod loader on %s', + (hostname) => { + setHostname(hostname); + + makeCmp().load({ locale: 'en_GB', region: 'CA' }); + + expect(injectScript.mock.calls[0]?.[0]).toBe( + LOADER_URLS.preproduction, + ); + expect(testWindow.__cmpConfig).toMatchObject({ + locale: 'en-GB', + region: 'CA', + environment: 'preproduction', + }); + }, + ); + + it('flags an error without injecting anything when the environment has no loader URL', async () => { + // Preprod URL unset (e.g. variable not provided to the build) while + // running on a non-production hostname. + setHostname('localhost'); + const cmp = createCmp({ + injectScript, + loaderUrls: { production: LOADER_URLS.production, preproduction: '' }, + }); + + cmp.load({ locale: 'fr_FR', region: 'EU' }); + + expect(injectScript).not.toHaveBeenCalled(); + expect(testWindow.__cmpConfig).toBeUndefined(); + // Resolves immediately (no timeout wait): the caller falls back to the + // legacy consent modal right away. + await expect(cmp.whenReady()).resolves.toBeUndefined(); + expect(cmp.isError()).toBe(true); + }); + + it('is idempotent', () => { + const cmp = makeCmp(); + cmp.load({ locale: 'fr_FR', region: 'EU' }); + cmp.load({ locale: 'fr_FR', region: 'EU' }); + + expect(injectScript).toHaveBeenCalledTimes(1); + }); + }); + + describe('whenReady', () => { + it('resolves on cmp:ready without error', async () => { + const cmp = makeCmp(); + cmp.load({ locale: 'fr_FR', region: 'EU' }); + + simulateCmpReady(); + await expect(cmp.whenReady()).resolves.toBeUndefined(); + expect(cmp.isError()).toBe(false); + }); + + it('short-circuits when window.__cmp is already up', async () => { + testWindow.__cmp = vi.fn(); + const cmp = makeCmp(); + cmp.load({ locale: 'fr_FR', region: 'EU' }); + + await expect(cmp.whenReady()).resolves.toBeUndefined(); + expect(cmp.isError()).toBe(false); + }); + + it('flags an error when cmp:ready never fires', async () => { + const cmp = makeCmp(); + cmp.load({ locale: 'fr_FR', region: 'EU' }); + + await vi.advanceTimersByTimeAsync(CMP_READY_TIMEOUT_MS); + + await expect(cmp.whenReady()).resolves.toBeUndefined(); + expect(cmp.isError()).toBe(true); + }); + + it('flags an error when the loader script fails to load', async () => { + injectScript.mockImplementationOnce( + (_src: string, onError: () => void) => { + onError(); + }, + ); + const cmp = makeCmp(); + cmp.load({ locale: 'fr_FR', region: 'EU' }); + + await expect(cmp.whenReady()).resolves.toBeUndefined(); + expect(cmp.isError()).toBe(true); + }); + }); + + describe('consent API', () => { + it('getConsent returns null while the CMP is not up', () => { + expect(makeCmp().getConsent()).toBeNull(); + }); + + it('getConsent delegates to window.__cmp once up', () => { + const choices = { analytics: true, marketing: false }; + testWindow.__cmp = vi.fn().mockReturnValue(choices); + + expect(makeCmp().getConsent()).toEqual(choices); + }); + + it('onConsentChange subscribes after cmp:ready and returns an unsubscribe', async () => { + const cmpUnsubscribe = vi.fn(); + const api = vi.fn().mockReturnValue(cmpUnsubscribe); + const callback = vi.fn(); + const cmp = makeCmp(); + cmp.load({ locale: 'fr_FR', region: 'EU' }); + + const unsubscribe = cmp.onConsentChange(callback); + expect(api).not.toHaveBeenCalled(); + + simulateCmpReady(api); + await vi.advanceTimersByTimeAsync(0); + expect(api).toHaveBeenCalledWith('onConsentChange', callback); + + unsubscribe(); + expect(cmpUnsubscribe).toHaveBeenCalledTimes(1); + }); + + it('onConsentChange never subscribes when unsubscribed before ready', async () => { + const api = vi.fn(); + const cmp = makeCmp(); + cmp.load({ locale: 'fr_FR', region: 'EU' }); + + cmp.onConsentChange(vi.fn())(); + + simulateCmpReady(api); + await vi.advanceTimersByTimeAsync(0); + expect(api).not.toHaveBeenCalledWith( + 'onConsentChange', + expect.anything(), + ); + }); + + it('showPreferences delegates to window.__cmp once up', async () => { + const api = vi.fn(); + const cmp = makeCmp(); + cmp.load({ locale: 'fr_FR', region: 'EU' }); + + cmp.showPreferences(); + simulateCmpReady(api); + await vi.advanceTimersByTimeAsync(0); + + expect(api).toHaveBeenCalledWith('showPreferences'); + }); + }); +}); diff --git a/packages/manager/apps/container/src/cmp/cmp.ts b/packages/manager/apps/container/src/cmp/cmp.ts new file mode 100644 index 000000000000..9083c4264d1c --- /dev/null +++ b/packages/manager/apps/container/src/cmp/cmp.ts @@ -0,0 +1,216 @@ +import { + CMP_LOADER_URLS, + CMP_PROD_HOSTNAME_RE, + CMP_READY_EVENT, + CMP_READY_TIMEOUT_MS, +} from './cmp.constants'; + +/** + * CMP facade — loads the OVHcloud Consent Management Platform and wraps its + * `window.__cmp` API. + * + * The container is the host-page side of the CMP contract: it DECLARES the + * page context in `window.__cmpConfig` (locale / region / environment) and + * injects the stable-name loader; the CMP does the rest (consent banner, + * preferences modal, `cmp_consent` cookie). Consent is then applied to the + * shell tracking plugin by the CookiePolicy bridge. + * + * `scripts` is intentionally empty: the Manager V6 tracking SDK (Piano) is + * bundled and consent-gated in the tracking plugin — the CMP owns the consent + * UI and cookie only, it injects no tag container in this app. + * + * Failure posture: the `cmp:ready` listener is armed before the loader is + * injected (the CMP dispatches it synchronously), the loader carries an + * `onerror`, and `cmp:ready` is time-bounded. Every promise resolves — + * `isError()` carries the diagnosis so the caller can fall back to the legacy + * consent modal. + */ + +export type CmpChoices = Record; + +export type CmpApi = (command: string, ...args: unknown[]) => unknown; + +export interface CmpRuntimeConfig { + /** UI locale, BCP-47 (e.g. `"fr-FR"`). */ + locale: string; + region: 'EU' | 'CA'; + environment: 'production' | 'preproduction'; + /** Script URLs the CMP injects — always empty in the Manager V6. */ + scripts: string[]; +} + +interface CmpWindow { + __cmpConfig?: CmpRuntimeConfig; + __cmp?: CmpApi; +} + +export interface CmpLoadParams { + /** Manager locale, snake_case (e.g. `"fr_FR"`) — converted to BCP-47. */ + locale: string; + region: 'EU' | 'CA'; +} + +export type InjectScript = (src: string, onError: () => void) => void; + +export interface Cmp { + /** + * Declares `window.__cmpConfig` then injects the CMP loader. Idempotent, + * never throws, no-op outside the DOM. Only called for EU/CA regions. + */ + load(params: CmpLoadParams): void; + /** + * Resolves once `window.__cmp` is available (on `cmp:ready`, with a + * synchronous short-circuit when already up). Always resolves — after + * {@link CMP_READY_TIMEOUT_MS} the CMP is flagged as failed instead. + */ + whenReady(): Promise; + /** Whether the CMP failed to come up (loader error / ready timeout). */ + isError(): boolean; + /** Current choices per category, or `null` (CMP not up / no consent yet). */ + getConsent(): CmpChoices | null; + /** + * Subscribes to consent changes (collected / updated / withdrawn). Safe to + * call before the CMP is up — armed on `cmp:ready`. + */ + onConsentChange(callback: (choices: CmpChoices) => void): () => void; + /** Opens the CMP preferences modal (queued until the CMP is up). */ + showPreferences(): void; +} + +const defaultInjectScript: InjectScript = (src, onError) => { + const script = document.createElement('script'); + script.src = src; + script.defer = true; + script.onerror = onError; + (document.head || document.body).appendChild(script); +}; + +const toBcp47 = (locale: string): string => locale.replace('_', '-'); + +export function createCmp( + deps: { + injectScript?: InjectScript; + loaderUrls?: Record<'production' | 'preproduction', string>; + } = {}, +): Cmp { + const injectScript = deps.injectScript ?? defaultInjectScript; + const loaderUrls = deps.loaderUrls ?? CMP_LOADER_URLS; + + let loaded = false; + let failed = false; + let readyPromise: Promise | null = null; + let settleReady: (() => void) | null = null; + + const win = (): CmpWindow => (window as unknown) as CmpWindow; + + const isApiReady = (): boolean => typeof win().__cmp === 'function'; + + /** Flags the CMP as failed and unblocks anything awaiting `cmp:ready`. */ + const markFailed = (): void => { + failed = true; + if (settleReady) settleReady(); + }; + + const whenReady = (): Promise => { + if (!readyPromise) { + readyPromise = new Promise((resolve) => { + if (typeof window === 'undefined' || failed || isApiReady()) { + resolve(); + return; + } + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + settleReady = null; + resolve(); + }; + settleReady = settle; + window.addEventListener(CMP_READY_EVENT, settle, { once: true }); + // Fail-safe: cmp:ready may never fire (loader unavailable/blocked). + setTimeout(() => { + if (!settled && !isApiReady()) failed = true; + settle(); + }, CMP_READY_TIMEOUT_MS); + }); + } + return readyPromise; + }; + + const load = ({ locale, region }: CmpLoadParams): void => { + if (loaded || typeof document === 'undefined') return; + loaded = true; + + // The same build artifact is deployed to every environment — the + // effective loader URL is selected at runtime from the hostname. + const environment = CMP_PROD_HOSTNAME_RE.test(window.location.hostname) + ? 'production' + : 'preproduction'; + const loaderUrl = loaderUrls[environment]; + + // No loader URL provided at build time for this environment: the CMP is + // unavailable here — flag it so the caller falls back to the legacy + // consent modal immediately, without waiting for the timeout. + if (!loaderUrl) { + markFailed(); + return; + } + + // Arm the cmp:ready listener BEFORE injecting the loader: the CMP + // dispatches it synchronously as soon as its bundle runs. + whenReady(); + + // Must be set BEFORE the bundle loads (read once at module-init). + win().__cmpConfig = { + locale: toBcp47(locale), + region, + environment, + scripts: [], + }; + + injectScript(loaderUrl, markFailed); + }; + + const getConsent = (): CmpChoices | null => { + const api = win().__cmp; + if (typeof api !== 'function') return null; + return (api('getConsent') as CmpChoices | null) ?? null; + }; + + const onConsentChange = ( + callback: (choices: CmpChoices) => void, + ): (() => void) => { + let unsubscribe: (() => void) | null = null; + let cancelled = false; + whenReady().then(() => { + if (cancelled) return; + const api = win().__cmp; + if (typeof api !== 'function') return; + const result = api('onConsentChange', callback); + if (typeof result === 'function') unsubscribe = result as () => void; + }); + return () => { + cancelled = true; + if (unsubscribe) unsubscribe(); + }; + }; + + const showPreferences = (): void => { + whenReady().then(() => { + const api = win().__cmp; + if (typeof api === 'function') api('showPreferences'); + }); + }; + + return { + load, + whenReady, + isError: () => failed, + getConsent, + onConsentChange, + showPreferences, + }; +} + +/** Container-wide singleton — the CookiePolicy bridge is the only driver. */ +export const cmp = createCmp(); diff --git a/packages/manager/apps/container/src/cmp/index.ts b/packages/manager/apps/container/src/cmp/index.ts new file mode 100644 index 000000000000..4168c95a51fa --- /dev/null +++ b/packages/manager/apps/container/src/cmp/index.ts @@ -0,0 +1,9 @@ +export { cmp, createCmp } from './cmp'; +export type { + Cmp, + CmpApi, + CmpChoices, + CmpLoadParams, + CmpRuntimeConfig, + InjectScript, +} from './cmp'; diff --git a/packages/manager/apps/container/src/cookie-policy/CookiePolicy.spec.tsx b/packages/manager/apps/container/src/cookie-policy/CookiePolicy.spec.tsx index b188a4453c98..40b26b623277 100644 --- a/packages/manager/apps/container/src/cookie-policy/CookiePolicy.spec.tsx +++ b/packages/manager/apps/container/src/cookie-policy/CookiePolicy.spec.tsx @@ -1,19 +1,35 @@ import { render, waitFor } from '@testing-library/react'; -import { it, vi, describe, expect } from 'vitest'; +import { it, vi, describe, expect, beforeEach } from 'vitest'; import { initShell } from '@ovh-ux/shell'; import { Environment, User } from '@ovh-ux/manager-config'; import { useCookies } from 'react-cookie'; import CookiePolicy from './CookiePolicy'; import ApplicationContext from '@/context/application.context'; import { WEBSITE_PRIVACY_COOKIE_NAME, WEBSITE_TRACKING_CONSENT_VALUE } from './CookiePolicy.constants'; +import { cmp } from '@/cmp'; +import type { CmpChoices } from '@/cmp'; const onValidate = vi.fn(); const trackingInit = vi.fn().mockResolvedValue(undefined); const trackingSetEnabled = vi.fn().mockResolvedValue(undefined); const trackingOnConsentModalDisplay = vi.fn().mockResolvedValue(undefined); +const trackingOnUserConsentFromModal = vi.fn().mockResolvedValue(undefined); vi.mock('@ovh-ux/shell'); +vi.mock('@/cmp', () => ({ + cmp: { + load: vi.fn(), + whenReady: vi.fn().mockResolvedValue(undefined), + isError: vi.fn().mockReturnValue(false), + getConsent: vi.fn().mockReturnValue(null), + onConsentChange: vi.fn().mockReturnValue(() => {}), + showPreferences: vi.fn(), + }, +})); + +const mockedCmp = vi.mocked(cmp); + const renderCookiePolicy = async () => { const shell = initShell({} as Environment); const environment = shell.getPlugin('environment').getEnvironment(); @@ -34,6 +50,7 @@ const mockedShell = (region: string) => ({ ovhSubsidiary: region, } as User, getRegion: () => region, + getUserLocale: () => 'fr_FR', } as Environment), }, tracking: { @@ -41,81 +58,129 @@ const mockedShell = (region: string) => ({ init: trackingInit, setEnabled: trackingSetEnabled, onConsentModalDisplay: trackingOnConsentModalDisplay, + onUserConsentFromModal: trackingOnUserConsentFromModal, }, }[plugin]), }); vi.mock('react-cookie'); +const mockShellForRegion = async (region: string) => { + (await import('@ovh-ux/shell')).initShell = vi + .fn() + .mockReturnValue(mockedShell(region)); +}; + +const mockCookieValue = (value: string | null) => { + vi.mocked(useCookies).mockReturnValue([ + { [WEBSITE_PRIVACY_COOKIE_NAME]: value }, + vi.fn(), + vi.fn(), + ]); +}; + describe('CookiePolicy.component', () => { - afterEach(() => { - vi.restoreAllMocks(); + beforeEach(() => { + vi.clearAllMocks(); + mockedCmp.whenReady.mockResolvedValue(undefined); + mockedCmp.isError.mockReturnValue(false); + mockedCmp.getConsent.mockReturnValue(null); + mockedCmp.onConsentChange.mockReturnValue(() => {}); + mockCookieValue(null); }); - it.each([ - ['EU', 'valid'], - ['US', 'invalid'], - ])( - 'should init tracking if region is %s and cookie is %s', - async (region, cookieValidity) => { - const cookieValue = cookieValidity === 'valid' ? WEBSITE_TRACKING_CONSENT_VALUE : '0'; - vi.mocked(useCookies).mockReturnValue([ - { [WEBSITE_PRIVACY_COOKIE_NAME]: cookieValue }, - vi.fn(), - vi.fn(), - ]); - (await import('@ovh-ux/shell')).initShell = vi - .fn() - .mockReturnValue(mockedShell(region)); - renderCookiePolicy(); - await waitFor( - () => { - expect(trackingInit).toHaveBeenCalledWith(true); - expect(trackingSetEnabled).not.toHaveBeenCalled(); - expect(trackingOnConsentModalDisplay).not.toHaveBeenCalled(); - }, - { timeout: 2000 }, - ); - }, - ); + it('US region: auto-enables tracking and never loads the CMP', async () => { + await mockShellForRegion('US'); + renderCookiePolicy(); + await waitFor(() => { + expect(trackingInit).toHaveBeenCalledWith(true); + expect(mockedCmp.load).not.toHaveBeenCalled(); + }); + }); - it('should show consent modal if cookie is null and region is not US', async () => { - vi.mocked(useCookies).mockReturnValue([ - { [WEBSITE_PRIVACY_COOKIE_NAME]: null }, - vi.fn(), - vi.fn(), - ]); - (await import('@ovh-ux/shell')).initShell = vi - .fn() - .mockReturnValue(mockedShell('EU')); + it('EU with CMP analytics consent: enables tracking', async () => { + mockedCmp.getConsent.mockReturnValue({ analytics: true } as CmpChoices); + await mockShellForRegion('EU'); + renderCookiePolicy(); + await waitFor(() => { + expect(mockedCmp.load).toHaveBeenCalledWith({ + locale: 'fr_FR', + region: 'EU', + }); + expect(trackingInit).toHaveBeenCalledWith(true); + expect(trackingOnConsentModalDisplay).not.toHaveBeenCalled(); + }); + }); + + it('EU with CMP analytics refused: disables tracking', async () => { + mockedCmp.getConsent.mockReturnValue({ analytics: false } as CmpChoices); + await mockShellForRegion('EU'); + renderCookiePolicy(); + await waitFor(() => { + expect(trackingSetEnabled).toHaveBeenCalledWith(false); + expect(trackingInit).not.toHaveBeenCalled(); + }); + }); + + it('EU without consent yet: waits in beforeConsent mode, no legacy modal', async () => { + mockedCmp.getConsent.mockReturnValue(null); + await mockShellForRegion('EU'); const { container } = await renderCookiePolicy(); - await waitFor( - () => { - expect(container).toBeAccessible(); - expect(trackingInit).not.toHaveBeenCalled(); - expect(trackingSetEnabled).not.toHaveBeenCalled(); - expect(trackingOnConsentModalDisplay).toHaveBeenCalled(); - }, - { timeout: 2000 }, - ); + await waitFor(() => { + expect(trackingOnConsentModalDisplay).toHaveBeenCalled(); + expect(trackingInit).not.toHaveBeenCalled(); + }); + // The consent UI is the CMP banner — the legacy OSDS modal must not show. + expect(container.querySelector('osds-modal')).toBeNull(); }); - it('should disable tracking plugin if cookie is invalid and region is not US', async () => { - vi.mocked(useCookies).mockReturnValue([ - { [WEBSITE_PRIVACY_COOKIE_NAME]: '0' }, - vi.fn(), - vi.fn(), - ]); - (await import('@ovh-ux/shell')).initShell = vi - .fn() - .mockReturnValue(mockedShell('EU')); + it('forwards CMP consent changes to the tracking plugin', async () => { + let consentCallback: (choices: CmpChoices) => void = () => {}; + mockedCmp.onConsentChange.mockImplementation((callback) => { + consentCallback = callback; + return () => {}; + }); + await mockShellForRegion('EU'); renderCookiePolicy(); - await waitFor( - () => { - expect(trackingInit).not.toHaveBeenCalled(); + await waitFor(() => expect(mockedCmp.onConsentChange).toHaveBeenCalled()); + + consentCallback({ analytics: true }); + expect(trackingOnUserConsentFromModal).toHaveBeenCalledWith(true); + + consentCallback({ analytics: false }); + expect(trackingOnUserConsentFromModal).toHaveBeenCalledWith(false); + }); + + describe('CMP failure fallback (legacy TC_PRIVACY_CENTER path)', () => { + beforeEach(() => { + mockedCmp.isError.mockReturnValue(true); + }); + + it('inits tracking when the legacy cookie holds consent', async () => { + mockCookieValue(WEBSITE_TRACKING_CONSENT_VALUE); + await mockShellForRegion('EU'); + renderCookiePolicy(); + await waitFor(() => { + expect(trackingInit).toHaveBeenCalledWith(true); + }); + }); + + it('shows the legacy modal when no legacy cookie exists', async () => { + mockCookieValue(null); + await mockShellForRegion('EU'); + const { container } = await renderCookiePolicy(); + await waitFor(() => { + expect(trackingOnConsentModalDisplay).toHaveBeenCalled(); + expect(container.querySelector('osds-modal')).not.toBeNull(); + }); + }); + + it('disables tracking when the legacy cookie refuses consent', async () => { + mockCookieValue('0'); + await mockShellForRegion('EU'); + renderCookiePolicy(); + await waitFor(() => { expect(trackingSetEnabled).toHaveBeenCalledWith(false); - expect(trackingOnConsentModalDisplay).not.toHaveBeenCalled(); - }, - { timeout: 2000 }, - ); + }); + }); }); }); diff --git a/packages/manager/apps/container/src/cookie-policy/CookiePolicy.tsx b/packages/manager/apps/container/src/cookie-policy/CookiePolicy.tsx index 5e7c9cc058b9..f3dd7ffff54b 100644 --- a/packages/manager/apps/container/src/cookie-policy/CookiePolicy.tsx +++ b/packages/manager/apps/container/src/cookie-policy/CookiePolicy.tsx @@ -19,6 +19,7 @@ import { import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; import { OdsHTMLAnchorElementTarget } from '@ovhcloud/ods-common-core'; import { useApplication } from '@/context'; +import { cmp } from '@/cmp'; import links from './links'; import ovhCloudLogo from '../assets/images/logo-ovhcloud.png'; import { WEBSITE_PRIVACY_COOKIE_NAME, WEBSITE_TRACKING_CONSENT_VALUE } from './CookiePolicy.constants'; @@ -73,13 +74,22 @@ const CookiePolicy = ({ shell, onValidate }: Props): JSX.Element => { onValidate(); }; - useEffect(() => { - const isRegionUS = environment.getRegion() === 'US'; - trackingPlugin.setRegion(environment.getRegion()); - const hasConsent = cookies[WEBSITE_PRIVACY_COOKIE_NAME]?.includes(WEBSITE_TRACKING_CONSENT_VALUE) ?? false; + // Applies an existing consent state to the tracking plugin at boot. + const applyInitialConsent = (agreed: boolean) => { + if (agreed) { + trackingPlugin.init(true); + } else { + trackingPlugin.setEnabled(false); + deleteCookie('clientSideUserId'); + } + onValidate(agreed); + }; - // activate tracking if region is US or if tracking consent cookie is valid - if (isRegionUS || hasConsent) { + // Legacy TC_PRIVACY_CENTER decision — kept as the fallback when the CMP + // cannot come up (loader blocked, outage), so consent collection survives. + const applyLegacyDecision = () => { + const hasConsent = cookies[WEBSITE_PRIVACY_COOKIE_NAME]?.includes(WEBSITE_TRACKING_CONSENT_VALUE) ?? false; + if (hasConsent) { trackingPlugin.init(true); } else if (cookies[WEBSITE_PRIVACY_COOKIE_NAME] == null) { trackingPlugin.onConsentModalDisplay(); @@ -88,8 +98,52 @@ const CookiePolicy = ({ shell, onValidate }: Props): JSX.Element => { trackingPlugin.setEnabled(false); deleteCookie('clientSideUserId'); } - onValidate(isRegionUS || hasConsent); - }, [show]); + onValidate(hasConsent); + }; + + useEffect(() => { + const region = environment.getRegion(); + trackingPlugin.setRegion(region); + + // US: no CMP (different legal framework) — tracking auto-enabled, as before. + if (region === 'US') { + trackingPlugin.init(true); + onValidate(true); + return undefined; + } + + // EU/CA: the CMP owns the consent UI and the cmp_consent cookie; the + // Manager V6 maps its single tracking consent onto the CMP `analytics` + // category. The CMP injects no script here (scripts: []) — Piano stays + // bundled and gated by the tracking plugin. + cmp.load({ locale: environment.getUserLocale(), region }); + + let unsubscribe: (() => void) | undefined; + let cancelled = false; + cmp.whenReady().then(() => { + if (cancelled) return; + if (cmp.isError()) { + applyLegacyDecision(); + return; + } + const choices = cmp.getConsent(); + if (choices) { + applyInitialConsent(Boolean(choices.analytics)); + } else { + // No consent yet — the CMP banner is on screen. + trackingPlugin.onConsentModalDisplay(); + onValidate(false); + } + unsubscribe = cmp.onConsentChange((newChoices) => { + trackingPlugin.onUserConsentFromModal(Boolean(newChoices.analytics)); + onValidate(Boolean(newChoices.analytics)); + }); + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, []); return ( <> diff --git a/packages/manager/apps/container/vite-env.d.ts b/packages/manager/apps/container/vite-env.d.ts index e82fcb39d65c..7134ca58374c 100644 --- a/packages/manager/apps/container/vite-env.d.ts +++ b/packages/manager/apps/container/vite-env.d.ts @@ -1,6 +1,6 @@ -// eslint-disable-next-line @typescript-eslint/naming-convention, no-underscore-dangle + declare const __VERSION__: string; -// eslint-disable-next-line @typescript-eslint/naming-convention, no-underscore-dangle + declare const __REGION__: string; declare global { @@ -10,6 +10,18 @@ declare global { } const __REGION__: string; const __VERSION__: string; + + interface ImportMetaEnv { + /** + * Stable-name CMP loader URL used on production hostnames. Both CMP + * variables ship in the same build; the effective one is selected at + * runtime from the hostname. Unset = CMP unavailable there, the legacy + * consent modal takes over. + */ + readonly VITE_CMP_LOADER_URL?: string; + /** Stable-name CMP loader URL used on non-production hostnames. */ + readonly VITE_CMP_LOADER_URL_PREPROD?: string; + } } export {} \ No newline at end of file