feat: initial public release
ConsentOS — a privacy-first cookie consent management platform. Self-hosted, source-available alternative to OneTrust, Cookiebot, and CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant architecture with role-based access, configuration cascade (system → org → group → site → region), dark-pattern detection in the scanner, and a tamper-evident consent record audit trail. This is the initial public release. Prior development history is retained internally. See README.md for the feature list, architecture overview, and quick-start instructions. Licensed under the Elastic Licence 2.0 — self-host freely; do not resell as a managed service.
This commit is contained in:
248
apps/banner/src/__tests__/a11y.test.ts
Normal file
248
apps/banner/src/__tests__/a11y.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import {
|
||||
announce,
|
||||
createLiveRegion,
|
||||
focusFirst,
|
||||
getFocusableElements,
|
||||
onEscape,
|
||||
prefersReducedMotion,
|
||||
trapFocus,
|
||||
} from '../a11y';
|
||||
|
||||
describe('a11y', () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
describe('getFocusableElements', () => {
|
||||
it('should find buttons', () => {
|
||||
container.innerHTML = '<button>Click</button><button disabled>No</button>';
|
||||
const elements = getFocusableElements(container);
|
||||
expect(elements).toHaveLength(1);
|
||||
expect(elements[0].tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
it('should find links with href', () => {
|
||||
container.innerHTML = '<a href="/test">Link</a><a>No href</a>';
|
||||
const elements = getFocusableElements(container);
|
||||
expect(elements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should find inputs', () => {
|
||||
container.innerHTML = '<input type="text" /><input type="checkbox" disabled />';
|
||||
const elements = getFocusableElements(container);
|
||||
expect(elements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should find elements with tabindex', () => {
|
||||
container.innerHTML = '<div tabindex="0">Focusable</div><div tabindex="-1">Not focusable</div>';
|
||||
const elements = getFocusableElements(container);
|
||||
expect(elements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return empty array for no focusable elements', () => {
|
||||
container.innerHTML = '<div>Just text</div>';
|
||||
const elements = getFocusableElements(container);
|
||||
expect(elements).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should search shadow DOM when present', () => {
|
||||
const shadow = container.attachShadow({ mode: 'open' });
|
||||
shadow.innerHTML = '<button>Shadow button</button>';
|
||||
const elements = getFocusableElements(container);
|
||||
expect(elements).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trapFocus', () => {
|
||||
it('should wrap Tab from last to first element', () => {
|
||||
container.innerHTML = '<button id="first">First</button><button id="last">Last</button>';
|
||||
const first = container.querySelector('#first') as HTMLElement;
|
||||
const last = container.querySelector('#last') as HTMLElement;
|
||||
last.focus();
|
||||
|
||||
const cleanup = trapFocus(container);
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true });
|
||||
vi.spyOn(event, 'preventDefault');
|
||||
// Simulate activeElement being the last element
|
||||
vi.spyOn(document, 'activeElement', 'get').mockReturnValue(last);
|
||||
|
||||
container.dispatchEvent(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('should wrap Shift+Tab from first to last element', () => {
|
||||
container.innerHTML = '<button id="first">First</button><button id="last">Last</button>';
|
||||
const first = container.querySelector('#first') as HTMLElement;
|
||||
first.focus();
|
||||
|
||||
const cleanup = trapFocus(container);
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true });
|
||||
vi.spyOn(event, 'preventDefault');
|
||||
vi.spyOn(document, 'activeElement', 'get').mockReturnValue(first);
|
||||
|
||||
container.dispatchEvent(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('should not interfere with non-Tab keys', () => {
|
||||
container.innerHTML = '<button>Btn</button>';
|
||||
const cleanup = trapFocus(container);
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
|
||||
vi.spyOn(event, 'preventDefault');
|
||||
|
||||
container.dispatchEvent(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('should return a cleanup function that removes the listener', () => {
|
||||
container.innerHTML = '<button>Btn</button>';
|
||||
const cleanup = trapFocus(container);
|
||||
|
||||
const spy = vi.spyOn(container, 'removeEventListener');
|
||||
cleanup();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('onEscape', () => {
|
||||
it('should call callback on Escape key', () => {
|
||||
const callback = vi.fn();
|
||||
const cleanup = onEscape(container, callback);
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
|
||||
container.dispatchEvent(event);
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('should not call callback on other keys', () => {
|
||||
const callback = vi.fn();
|
||||
const cleanup = onEscape(container, callback);
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
|
||||
container.dispatchEvent(event);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('should return a cleanup function', () => {
|
||||
const callback = vi.fn();
|
||||
const cleanup = onEscape(container, callback);
|
||||
|
||||
cleanup();
|
||||
|
||||
// After cleanup, Escape should not trigger callback
|
||||
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
|
||||
container.dispatchEvent(event);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('focusFirst', () => {
|
||||
it('should focus the first focusable element', () => {
|
||||
container.innerHTML = '<div>Text</div><button id="btn">Button</button><input />';
|
||||
const btn = container.querySelector('#btn') as HTMLElement;
|
||||
const spy = vi.spyOn(btn, 'focus');
|
||||
|
||||
focusFirst(container);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing when no focusable elements', () => {
|
||||
container.innerHTML = '<div>Just text</div>';
|
||||
// Should not throw
|
||||
focusFirst(container);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createLiveRegion', () => {
|
||||
it('should create an element with role=status', () => {
|
||||
const region = createLiveRegion(container);
|
||||
|
||||
expect(region.getAttribute('role')).toBe('status');
|
||||
expect(region.getAttribute('aria-live')).toBe('polite');
|
||||
expect(region.getAttribute('aria-atomic')).toBe('true');
|
||||
});
|
||||
|
||||
it('should append the region to the container', () => {
|
||||
const region = createLiveRegion(container);
|
||||
|
||||
expect(container.contains(region)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have sr-only class for visual hiding', () => {
|
||||
const region = createLiveRegion(container);
|
||||
|
||||
expect(region.className).toBe('cmp-sr-only');
|
||||
});
|
||||
});
|
||||
|
||||
describe('announce', () => {
|
||||
it('should set text content on live region', async () => {
|
||||
const region = createLiveRegion(container);
|
||||
|
||||
announce(region, 'Preferences expanded');
|
||||
|
||||
// The announcement happens in the next animation frame
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
|
||||
expect(region.textContent).toBe('Preferences expanded');
|
||||
});
|
||||
|
||||
it('should clear before setting to trigger re-announcement', () => {
|
||||
const region = createLiveRegion(container);
|
||||
region.textContent = 'Old message';
|
||||
|
||||
announce(region, 'New message');
|
||||
|
||||
// Immediately after call, text should be cleared
|
||||
expect(region.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefersReducedMotion', () => {
|
||||
it('should return false by default in test environment', () => {
|
||||
// JSDOM defaults to no media query match
|
||||
expect(prefersReducedMotion()).toBe(false);
|
||||
});
|
||||
|
||||
it('should check the prefers-reduced-motion media query', () => {
|
||||
const matchMediaSpy = vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
matches: true,
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
} as MediaQueryList);
|
||||
|
||||
expect(prefersReducedMotion()).toBe(true);
|
||||
|
||||
matchMediaSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
368
apps/banner/src/__tests__/banner.test.ts
Normal file
368
apps/banner/src/__tests__/banner.test.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
// We need to test banner.ts functions, but it auto-inits on import.
|
||||
// We'll mock fetch and test the composed behaviour.
|
||||
|
||||
vi.mock('../blocker', () => ({
|
||||
updateAcceptedCategories: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../consent', () => ({
|
||||
buildConsentState: vi.fn(() => ({
|
||||
accepted: ['necessary'],
|
||||
rejected: ['analytics', 'marketing'],
|
||||
visitorId: 'v-test',
|
||||
consentedAt: new Date().toISOString(),
|
||||
})),
|
||||
readConsent: vi.fn(() => null),
|
||||
writeConsent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../gcm', () => ({
|
||||
buildGcmStateFromCategories: vi.fn(() => ({
|
||||
analytics_storage: 'denied',
|
||||
ad_storage: 'denied',
|
||||
security_storage: 'granted',
|
||||
})),
|
||||
updateGcm: vi.fn(),
|
||||
}));
|
||||
|
||||
import { updateAcceptedCategories } from '../blocker';
|
||||
import { buildConsentState, readConsent, writeConsent } from '../consent';
|
||||
import { buildGcmStateFromCategories, updateGcm } from '../gcm';
|
||||
import type { SiteConfig, CategorySlug } from '../types';
|
||||
|
||||
describe('banner', () => {
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
const ALL_CATEGORIES: CategorySlug[] = [
|
||||
'necessary', 'functional', 'analytics', 'marketing', 'personalisation',
|
||||
];
|
||||
|
||||
const NON_ESSENTIAL: CategorySlug[] = [
|
||||
'functional', 'analytics', 'marketing', 'personalisation',
|
||||
];
|
||||
|
||||
const defaultConfig: SiteConfig = {
|
||||
id: 'cfg-1',
|
||||
site_id: 'site-1',
|
||||
blocking_mode: 'opt_in',
|
||||
regional_modes: null,
|
||||
tcf_enabled: false,
|
||||
gpp_enabled: false,
|
||||
gpp_supported_apis: [],
|
||||
gpc_enabled: true,
|
||||
gpc_jurisdictions: [],
|
||||
gpc_global_honour: false,
|
||||
gcm_enabled: true,
|
||||
gcm_default: null,
|
||||
shopify_privacy_enabled: false,
|
||||
banner_config: null,
|
||||
privacy_policy_url: null,
|
||||
terms_url: null,
|
||||
consent_expiry_days: 365,
|
||||
consent_group_id: null,
|
||||
ab_test: null,
|
||||
initiator_map: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.dataLayer = [];
|
||||
window.__consentos = { siteId: 'site-1', apiBase: 'https://api.example.com', cdnBase: 'https://cdn.example.com', loaded: false };
|
||||
|
||||
mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up banner host if it was appended
|
||||
const host = document.getElementById('consentos-banner-host');
|
||||
if (host) host.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('buildDefaultConfig', () => {
|
||||
it('should create a valid default config', () => {
|
||||
// This mirrors what buildDefaultConfig does in the banner
|
||||
const config: SiteConfig = {
|
||||
id: '',
|
||||
site_id: 'test-site',
|
||||
blocking_mode: 'opt_in',
|
||||
regional_modes: null,
|
||||
tcf_enabled: false,
|
||||
gpp_enabled: false,
|
||||
gpp_supported_apis: [],
|
||||
gpc_enabled: true,
|
||||
gpc_jurisdictions: [],
|
||||
gpc_global_honour: false,
|
||||
gcm_enabled: true,
|
||||
gcm_default: null,
|
||||
shopify_privacy_enabled: false,
|
||||
banner_config: null,
|
||||
|
||||
privacy_policy_url: null,
|
||||
terms_url: null,
|
||||
consent_expiry_days: 365,
|
||||
consent_group_id: null,
|
||||
ab_test: null,
|
||||
initiator_map: null,
|
||||
};
|
||||
expect(config.blocking_mode).toBe('opt_in');
|
||||
expect(config.gcm_enabled).toBe(true);
|
||||
expect(config.consent_expiry_days).toBe(365);
|
||||
});
|
||||
});
|
||||
|
||||
describe('determineAction', () => {
|
||||
it('should return accept_all when no rejections', () => {
|
||||
const accepted: CategorySlug[] = ALL_CATEGORIES;
|
||||
const rejected: CategorySlug[] = [];
|
||||
|
||||
let action: string;
|
||||
if (rejected.length === 0) action = 'accept_all';
|
||||
else if (accepted.length === 1 && accepted[0] === 'necessary') action = 'reject_all';
|
||||
else action = 'custom';
|
||||
|
||||
expect(action).toBe('accept_all');
|
||||
});
|
||||
|
||||
it('should return reject_all when only necessary accepted', () => {
|
||||
const accepted: CategorySlug[] = ['necessary'];
|
||||
const rejected = NON_ESSENTIAL;
|
||||
|
||||
let action: string;
|
||||
if (rejected.length === 0) action = 'accept_all';
|
||||
else if (accepted.length === 1 && accepted[0] === 'necessary') action = 'reject_all';
|
||||
else action = 'custom';
|
||||
|
||||
expect(action).toBe('reject_all');
|
||||
});
|
||||
|
||||
it('should return custom when partial selection', () => {
|
||||
const accepted: CategorySlug[] = ['necessary', 'analytics'];
|
||||
const rejected: CategorySlug[] = ['marketing', 'functional', 'personalisation'];
|
||||
|
||||
let action: string;
|
||||
if (rejected.length === 0) action = 'accept_all';
|
||||
else if (accepted.length === 1 && accepted[0] === 'necessary') action = 'reject_all';
|
||||
else action = 'custom';
|
||||
|
||||
expect(action).toBe('custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleConsent flow', () => {
|
||||
it('should write consent, update blocker, and update GCM', () => {
|
||||
const accepted: CategorySlug[] = ['necessary', 'analytics'];
|
||||
const rejected: CategorySlug[] = ['marketing', 'functional', 'personalisation'];
|
||||
|
||||
const gcmState = buildGcmStateFromCategories(accepted);
|
||||
const state = buildConsentState(accepted, rejected);
|
||||
|
||||
writeConsent(state, defaultConfig.consent_expiry_days);
|
||||
updateAcceptedCategories(accepted);
|
||||
|
||||
if (defaultConfig.gcm_enabled) {
|
||||
updateGcm(gcmState);
|
||||
}
|
||||
|
||||
expect(writeConsent).toHaveBeenCalled();
|
||||
expect(updateAcceptedCategories).toHaveBeenCalledWith(accepted);
|
||||
expect(updateGcm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT call updateGcm when gcm_enabled is false', () => {
|
||||
const config = { ...defaultConfig, gcm_enabled: false };
|
||||
const accepted: CategorySlug[] = ['necessary'];
|
||||
const rejected = NON_ESSENTIAL;
|
||||
|
||||
const gcmState = buildGcmStateFromCategories(accepted);
|
||||
const state = buildConsentState(accepted, rejected);
|
||||
writeConsent(state, config.consent_expiry_days);
|
||||
updateAcceptedCategories(accepted);
|
||||
|
||||
if (config.gcm_enabled) {
|
||||
updateGcm(gcmState);
|
||||
}
|
||||
|
||||
expect(updateGcm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should post consent to the API', () => {
|
||||
const accepted: CategorySlug[] = ALL_CATEGORIES;
|
||||
const rejected: CategorySlug[] = [];
|
||||
|
||||
mockFetch.mockResolvedValue(new Response('', { status: 201 }));
|
||||
|
||||
fetch('https://api.example.com/api/v1/consent/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
site_id: 'site-1',
|
||||
visitor_id: 'v-test',
|
||||
action: 'accept_all',
|
||||
categories_accepted: accepted,
|
||||
categories_rejected: rejected,
|
||||
gcm_state: { analytics_storage: 'granted' },
|
||||
page_url: window.location.href,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.example.com/api/v1/consent/',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch consent-change event', () => {
|
||||
const accepted: CategorySlug[] = ['necessary', 'functional'];
|
||||
let receivedDetail: unknown = null;
|
||||
|
||||
document.addEventListener('consentos:consent-change', ((e: CustomEvent) => {
|
||||
receivedDetail = e.detail;
|
||||
}) as EventListener);
|
||||
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('consentos:consent-change', { detail: { accepted } })
|
||||
);
|
||||
|
||||
expect(receivedDetail).toEqual({ accepted });
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderBanner', () => {
|
||||
it('should create a shadow DOM host element', () => {
|
||||
const host = document.createElement('div');
|
||||
host.id = 'consentos-banner-host';
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
|
||||
shadow.innerHTML = `
|
||||
<div class="consentos-banner" role="dialog" aria-label="Cookie consent">
|
||||
<button data-action="accept">Accept all</button>
|
||||
<button data-action="reject">Reject all</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(host);
|
||||
|
||||
const bannerHost = document.getElementById('consentos-banner-host');
|
||||
expect(bannerHost).not.toBeNull();
|
||||
expect(bannerHost?.shadowRoot).not.toBeNull();
|
||||
|
||||
const banner = bannerHost?.shadowRoot?.querySelector('.consentos-banner');
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner?.getAttribute('role')).toBe('dialog');
|
||||
});
|
||||
|
||||
it('should render category toggles', () => {
|
||||
const categories = [
|
||||
{ slug: 'necessary', name: 'Necessary', locked: true },
|
||||
{ slug: 'analytics', name: 'Analytics', locked: false },
|
||||
];
|
||||
|
||||
const html = categories
|
||||
.map(
|
||||
(cat) =>
|
||||
`<label class="cmp-category">
|
||||
<span>${cat.name}</span>
|
||||
<input type="checkbox" data-category="${cat.slug}" ${cat.locked ? 'checked disabled' : ''} />
|
||||
</label>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
|
||||
const inputs = div.querySelectorAll<HTMLInputElement>('input[data-category]');
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[0].disabled).toBe(true);
|
||||
expect(inputs[0].checked).toBe(true);
|
||||
expect(inputs[1].disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should include privacy policy link when URL is provided', () => {
|
||||
const url = 'https://example.com/privacy';
|
||||
const html = `<a href="${url}" target="_blank" rel="noopener">Privacy Policy</a>`;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
|
||||
const link = div.querySelector('a');
|
||||
expect(link?.href).toBe(url);
|
||||
expect(link?.target).toBe('_blank');
|
||||
expect(link?.rel).toBe('noopener');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeBanner', () => {
|
||||
it('should remove the banner host from DOM', async () => {
|
||||
const host = document.createElement('div');
|
||||
host.id = 'consentos-banner-host';
|
||||
document.body.appendChild(host);
|
||||
|
||||
expect(document.getElementById('consentos-banner-host')).not.toBeNull();
|
||||
|
||||
host.remove();
|
||||
|
||||
expect(document.getElementById('consentos-banner-host')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBannerStyles', () => {
|
||||
it('should use default colours when no banner_config', () => {
|
||||
const bc = defaultConfig.banner_config;
|
||||
const bg = bc?.backgroundColour ?? '#ffffff';
|
||||
const text = bc?.textColour ?? '#0E1929';
|
||||
const primary = bc?.primaryColour ?? '#2C6AE4';
|
||||
|
||||
expect(bg).toBe('#ffffff');
|
||||
expect(text).toBe('#0E1929');
|
||||
expect(primary).toBe('#2C6AE4');
|
||||
});
|
||||
|
||||
it('should use custom colours from banner_config', () => {
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
banner_config: {
|
||||
backgroundColour: '#000000',
|
||||
textColour: '#ffffff',
|
||||
primaryColour: '#ff0000',
|
||||
},
|
||||
};
|
||||
|
||||
const bc = config.banner_config;
|
||||
const bg = bc?.backgroundColour ?? '#ffffff';
|
||||
const text = bc?.textColour ?? '#0E1929';
|
||||
const primary = bc?.primaryColour ?? '#ff0000';
|
||||
|
||||
expect(bg).toBe('#000000');
|
||||
expect(text).toBe('#ffffff');
|
||||
expect(primary).toBe('#ff0000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSelectedCategories', () => {
|
||||
it('should return necessary plus checked categories', () => {
|
||||
const host = document.createElement('div');
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
shadow.innerHTML = `
|
||||
<input type="checkbox" data-category="necessary" checked disabled />
|
||||
<input type="checkbox" data-category="analytics" checked />
|
||||
<input type="checkbox" data-category="marketing" />
|
||||
`;
|
||||
|
||||
const checked: CategorySlug[] = ['necessary'];
|
||||
shadow.querySelectorAll<HTMLInputElement>('input[data-category]').forEach((input) => {
|
||||
if (input.checked) {
|
||||
checked.push(input.getAttribute('data-category') as CategorySlug);
|
||||
}
|
||||
});
|
||||
const unique = [...new Set(checked)];
|
||||
|
||||
expect(unique).toContain('necessary');
|
||||
expect(unique).toContain('analytics');
|
||||
expect(unique).not.toContain('marketing');
|
||||
});
|
||||
});
|
||||
});
|
||||
333
apps/banner/src/__tests__/blocker.test.ts
Normal file
333
apps/banner/src/__tests__/blocker.test.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
installBlocker,
|
||||
uninstallBlocker,
|
||||
updateAcceptedCategories,
|
||||
getBlockedCount,
|
||||
isCategoryAllowed,
|
||||
addScriptPatterns,
|
||||
loadInitiatorMappings,
|
||||
} from '../blocker';
|
||||
|
||||
describe('blocker', () => {
|
||||
beforeEach(() => {
|
||||
installBlocker();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
uninstallBlocker();
|
||||
});
|
||||
|
||||
describe('installBlocker', () => {
|
||||
it('should only install once', () => {
|
||||
// Install a second time — should be a no-op
|
||||
installBlocker();
|
||||
expect(isCategoryAllowed('necessary')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow necessary category by default', () => {
|
||||
expect(isCategoryAllowed('necessary')).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny non-essential categories by default', () => {
|
||||
expect(isCategoryAllowed('analytics')).toBe(false);
|
||||
expect(isCategoryAllowed('marketing')).toBe(false);
|
||||
expect(isCategoryAllowed('functional')).toBe(false);
|
||||
expect(isCategoryAllowed('personalisation')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAcceptedCategories', () => {
|
||||
it('should update accepted categories', () => {
|
||||
updateAcceptedCategories(['necessary', 'analytics']);
|
||||
expect(isCategoryAllowed('analytics')).toBe(true);
|
||||
expect(isCategoryAllowed('marketing')).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept all categories when all are provided', () => {
|
||||
updateAcceptedCategories([
|
||||
'necessary',
|
||||
'functional',
|
||||
'analytics',
|
||||
'marketing',
|
||||
'personalisation',
|
||||
]);
|
||||
expect(isCategoryAllowed('functional')).toBe(true);
|
||||
expect(isCategoryAllowed('analytics')).toBe(true);
|
||||
expect(isCategoryAllowed('marketing')).toBe(true);
|
||||
expect(isCategoryAllowed('personalisation')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('script interception via createElement', () => {
|
||||
it('should override document.createElement', () => {
|
||||
// createElement should still work for non-script elements
|
||||
const div = document.createElement('div');
|
||||
expect(div).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
|
||||
it('should create script elements normally when no src is set', () => {
|
||||
const script = document.createElement('script');
|
||||
expect(script).toBeInstanceOf(HTMLScriptElement);
|
||||
expect(script.hasAttribute('data-consentos-blocked')).toBe(false);
|
||||
});
|
||||
|
||||
it('should mark analytics scripts as blocked when src is set', () => {
|
||||
const script = document.createElement('script') as HTMLScriptElement;
|
||||
script.src = 'https://www.google-analytics.com/analytics.js';
|
||||
expect(script.getAttribute('data-consentos-blocked')).toBe('true');
|
||||
expect(script.getAttribute('data-consentos-category')).toBe('analytics');
|
||||
expect(script.type).toBe('text/blocked');
|
||||
});
|
||||
|
||||
it('should mark marketing scripts as blocked', () => {
|
||||
const script = document.createElement('script') as HTMLScriptElement;
|
||||
script.src = 'https://connect.facebook.net/en_US/fbevents.js';
|
||||
expect(script.getAttribute('data-consentos-blocked')).toBe('true');
|
||||
expect(script.getAttribute('data-consentos-category')).toBe('marketing');
|
||||
});
|
||||
|
||||
it('should allow scripts when their category is accepted', () => {
|
||||
updateAcceptedCategories(['necessary', 'analytics']);
|
||||
const script = document.createElement('script') as HTMLScriptElement;
|
||||
script.src = 'https://www.google-analytics.com/analytics.js';
|
||||
// Should not be blocked
|
||||
expect(script.hasAttribute('data-consentos-blocked')).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow scripts with unknown src (no matching pattern)', () => {
|
||||
const script = document.createElement('script') as HTMLScriptElement;
|
||||
script.src = 'https://example.com/my-custom-script.js';
|
||||
expect(script.hasAttribute('data-consentos-blocked')).toBe(false);
|
||||
});
|
||||
|
||||
it('should respect explicit data-category attribute', () => {
|
||||
const script = document.createElement('script') as HTMLScriptElement;
|
||||
script.setAttribute('data-category', 'marketing');
|
||||
script.src = 'https://example.com/unknown-tracker.js';
|
||||
expect(script.getAttribute('data-consentos-blocked')).toBe('true');
|
||||
expect(script.getAttribute('data-consentos-category')).toBe('marketing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MutationObserver blocking', () => {
|
||||
it('should block a script inserted into the DOM', async () => {
|
||||
const script = document.createElement('script') as HTMLScriptElement;
|
||||
script.setAttribute('data-category', 'analytics');
|
||||
script.src = 'https://example.com/analytics.js';
|
||||
|
||||
document.head.appendChild(script);
|
||||
|
||||
// MutationObserver is async, wait a tick
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Script should have been removed from DOM and queued
|
||||
expect(script.parentNode).toBeNull();
|
||||
expect(getBlockedCount()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not block scripts marked as allowed', async () => {
|
||||
const script = document.createElement('script') as HTMLScriptElement;
|
||||
script.setAttribute('data-consentos-allowed', 'true');
|
||||
script.setAttribute('data-category', 'analytics');
|
||||
script.textContent = '/* allowed */';
|
||||
|
||||
document.head.appendChild(script);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Should still be in the DOM
|
||||
expect(script.parentNode).toBe(document.head);
|
||||
|
||||
// Clean up
|
||||
script.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('release manager', () => {
|
||||
it('should release blocked scripts when consent is granted', async () => {
|
||||
// Insert a blocked script
|
||||
const script = document.createElement('script') as HTMLScriptElement;
|
||||
script.setAttribute('data-category', 'analytics');
|
||||
script.src = 'https://example.com/analytics-lib.js';
|
||||
document.head.appendChild(script);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(getBlockedCount()).toBeGreaterThan(0);
|
||||
|
||||
const countBefore = getBlockedCount();
|
||||
|
||||
// Grant analytics consent
|
||||
updateAcceptedCategories(['necessary', 'analytics']);
|
||||
|
||||
// Blocked count should decrease
|
||||
expect(getBlockedCount()).toBeLessThan(countBefore);
|
||||
});
|
||||
|
||||
it('should not release scripts for non-consented categories', async () => {
|
||||
const script = document.createElement('script') as HTMLScriptElement;
|
||||
script.setAttribute('data-category', 'marketing');
|
||||
script.src = 'https://example.com/marketing.js';
|
||||
document.head.appendChild(script);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const count = getBlockedCount();
|
||||
|
||||
// Grant analytics only (not marketing)
|
||||
updateAcceptedCategories(['necessary', 'analytics']);
|
||||
|
||||
// Marketing scripts should still be blocked
|
||||
// Count may have decreased by analytics scripts but marketing should remain
|
||||
expect(getBlockedCount()).toBeGreaterThanOrEqual(count > 0 ? 1 : 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cookie proxy', () => {
|
||||
it('should allow CMP cookies to be set', () => {
|
||||
document.cookie = '_consentos_test=value; path=/';
|
||||
expect(document.cookie).toContain('_consentos_test=value');
|
||||
// Clean up
|
||||
document.cookie = '_consentos_test=; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
||||
});
|
||||
|
||||
it('should block known analytics cookies when not consented', () => {
|
||||
const before = document.cookie;
|
||||
document.cookie = '_ga=GA1.2.12345; path=/';
|
||||
// _ga should not appear (blocked)
|
||||
expect(document.cookie).not.toContain('_ga=GA1.2.12345');
|
||||
// Ensure we haven't corrupted the cookie string
|
||||
expect(document.cookie.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should allow analytics cookies when consented', () => {
|
||||
updateAcceptedCategories(['necessary', 'analytics']);
|
||||
document.cookie = '_ga=GA1.2.12345; path=/';
|
||||
expect(document.cookie).toContain('_ga=GA1.2.12345');
|
||||
// Clean up
|
||||
document.cookie = '_ga=; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
||||
});
|
||||
|
||||
it('should allow unknown cookies (not in any pattern)', () => {
|
||||
document.cookie = 'my_app_session=abc123; path=/';
|
||||
expect(document.cookie).toContain('my_app_session=abc123');
|
||||
// Clean up
|
||||
document.cookie = 'my_app_session=; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage proxy', () => {
|
||||
it('should block known analytics storage keys', () => {
|
||||
localStorage.setItem('_hjSession_12345', 'test');
|
||||
// Should be blocked — key should not exist
|
||||
expect(localStorage.getItem('_hjSession_12345')).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow storage writes when category is consented', () => {
|
||||
updateAcceptedCategories(['necessary', 'analytics']);
|
||||
localStorage.setItem('_hjSession_12345', 'test');
|
||||
expect(localStorage.getItem('_hjSession_12345')).toBe('test');
|
||||
// Clean up
|
||||
localStorage.removeItem('_hjSession_12345');
|
||||
});
|
||||
|
||||
it('should allow CMP storage keys', () => {
|
||||
localStorage.setItem('_consentos_state', 'test');
|
||||
expect(localStorage.getItem('_consentos_state')).toBe('test');
|
||||
// Clean up
|
||||
localStorage.removeItem('_consentos_state');
|
||||
});
|
||||
|
||||
it('should allow unknown storage keys', () => {
|
||||
localStorage.setItem('my_app_key', 'value');
|
||||
expect(localStorage.getItem('my_app_key')).toBe('value');
|
||||
// Clean up
|
||||
localStorage.removeItem('my_app_key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addScriptPatterns', () => {
|
||||
it('should add custom patterns for classification', () => {
|
||||
addScriptPatterns([
|
||||
{ pattern: 'custom-tracker\\.example\\.com', category: 'marketing' },
|
||||
]);
|
||||
|
||||
const script = document.createElement('script') as HTMLScriptElement;
|
||||
script.src = 'https://custom-tracker.example.com/track.js';
|
||||
expect(script.getAttribute('data-consentos-blocked')).toBe('true');
|
||||
expect(script.getAttribute('data-consentos-category')).toBe('marketing');
|
||||
});
|
||||
|
||||
it('should handle invalid patterns gracefully', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
addScriptPatterns([{ pattern: '[invalid', category: 'analytics' }]);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid script pattern')
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadInitiatorMappings', () => {
|
||||
it('should block scripts matching initiator mappings', () => {
|
||||
loadInitiatorMappings([
|
||||
{ root_script: 'gtm\\.example\\.com', category: 'marketing' },
|
||||
]);
|
||||
|
||||
const script = document.createElement('script') as HTMLScriptElement;
|
||||
script.src = 'https://gtm.example.com/gtm.js';
|
||||
expect(script.getAttribute('data-consentos-blocked')).toBe('true');
|
||||
expect(script.getAttribute('data-consentos-category')).toBe('marketing');
|
||||
});
|
||||
|
||||
it('should not block initiator scripts when category is consented', () => {
|
||||
updateAcceptedCategories(['necessary', 'marketing']);
|
||||
loadInitiatorMappings([
|
||||
{ root_script: 'gtm\\.example\\.com', category: 'marketing' },
|
||||
]);
|
||||
|
||||
const script = document.createElement('script') as HTMLScriptElement;
|
||||
script.src = 'https://gtm.example.com/gtm.js';
|
||||
expect(script.hasAttribute('data-consentos-blocked')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle invalid initiator patterns gracefully', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
loadInitiatorMappings([{ root_script: '[invalid', category: 'analytics' }]);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid initiator pattern')
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should prioritise URL patterns over initiator mappings', () => {
|
||||
// google-analytics.com matches the built-in analytics pattern
|
||||
loadInitiatorMappings([
|
||||
{ root_script: 'google-analytics\\.com', category: 'marketing' },
|
||||
]);
|
||||
|
||||
const script = document.createElement('script') as HTMLScriptElement;
|
||||
script.src = 'https://www.google-analytics.com/analytics.js';
|
||||
// Should match URL pattern (analytics) not initiator mapping (marketing)
|
||||
expect(script.getAttribute('data-consentos-category')).toBe('analytics');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstallBlocker', () => {
|
||||
it('should restore original document.createElement', () => {
|
||||
uninstallBlocker();
|
||||
const div = document.createElement('div');
|
||||
expect(div).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
|
||||
it('should reset blocked count to zero', () => {
|
||||
uninstallBlocker();
|
||||
expect(getBlockedCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should reset accepted categories to necessary only', () => {
|
||||
updateAcceptedCategories(['necessary', 'analytics', 'marketing']);
|
||||
uninstallBlocker();
|
||||
expect(isCategoryAllowed('analytics')).toBe(false);
|
||||
expect(isCategoryAllowed('necessary')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
apps/banner/src/__tests__/consent.test.ts
Normal file
104
apps/banner/src/__tests__/consent.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
buildConsentState,
|
||||
clearConsent,
|
||||
generateVisitorId,
|
||||
hasConsent,
|
||||
isCategoryAccepted,
|
||||
readConsent,
|
||||
writeConsent,
|
||||
} from '../consent';
|
||||
|
||||
describe('consent', () => {
|
||||
beforeEach(() => {
|
||||
// Clear all cookies
|
||||
document.cookie.split(';').forEach((c) => {
|
||||
const name = c.split('=')[0].trim();
|
||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateVisitorId', () => {
|
||||
it('should return a UUID-like string', () => {
|
||||
const id = generateVisitorId();
|
||||
expect(id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate unique IDs', () => {
|
||||
const id1 = generateVisitorId();
|
||||
const id2 = generateVisitorId();
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildConsentState', () => {
|
||||
it('should build state with accepted and rejected categories', () => {
|
||||
const state = buildConsentState(
|
||||
['necessary', 'analytics'],
|
||||
['marketing'],
|
||||
);
|
||||
expect(state.accepted).toEqual(['necessary', 'analytics']);
|
||||
expect(state.rejected).toEqual(['marketing']);
|
||||
expect(state.visitorId).toBeTruthy();
|
||||
expect(state.bannerVersion).toBe('0.1.0');
|
||||
expect(state.consentedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should use existing visitor ID if provided', () => {
|
||||
const state = buildConsentState(
|
||||
['necessary'],
|
||||
[],
|
||||
'existing-id-123',
|
||||
);
|
||||
expect(state.visitorId).toBe('existing-id-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('read/write/clear consent', () => {
|
||||
it('should return null when no consent cookie exists', () => {
|
||||
expect(readConsent()).toBeNull();
|
||||
expect(hasConsent()).toBe(false);
|
||||
});
|
||||
|
||||
it('should write and read consent state', () => {
|
||||
const state = buildConsentState(['necessary', 'analytics'], ['marketing']);
|
||||
writeConsent(state);
|
||||
|
||||
const read = readConsent();
|
||||
expect(read).not.toBeNull();
|
||||
expect(read!.accepted).toEqual(['necessary', 'analytics']);
|
||||
expect(read!.rejected).toEqual(['marketing']);
|
||||
expect(hasConsent()).toBe(true);
|
||||
});
|
||||
|
||||
it('should clear consent', () => {
|
||||
const state = buildConsentState(['necessary'], []);
|
||||
writeConsent(state);
|
||||
expect(hasConsent()).toBe(true);
|
||||
|
||||
clearConsent();
|
||||
expect(hasConsent()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCategoryAccepted', () => {
|
||||
it('should return true for necessary when no consent exists', () => {
|
||||
expect(isCategoryAccepted('necessary')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-necessary when no consent exists', () => {
|
||||
expect(isCategoryAccepted('analytics')).toBe(false);
|
||||
});
|
||||
|
||||
it('should check accepted categories from cookie', () => {
|
||||
const state = buildConsentState(['necessary', 'analytics'], ['marketing']);
|
||||
writeConsent(state);
|
||||
|
||||
expect(isCategoryAccepted('necessary')).toBe(true);
|
||||
expect(isCategoryAccepted('analytics')).toBe(true);
|
||||
expect(isCategoryAccepted('marketing')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
155
apps/banner/src/__tests__/gcm.test.ts
Normal file
155
apps/banner/src/__tests__/gcm.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
buildDeniedDefaults,
|
||||
buildGcmStateFromCategories,
|
||||
setGcmDefaults,
|
||||
updateGcm,
|
||||
} from '../gcm';
|
||||
|
||||
describe('gcm', () => {
|
||||
beforeEach(() => {
|
||||
// Reset dataLayer and gtag before each test
|
||||
window.dataLayer = [];
|
||||
// @ts-expect-error — resetting gtag for test isolation
|
||||
window.gtag = undefined;
|
||||
});
|
||||
|
||||
describe('buildDeniedDefaults', () => {
|
||||
it('should deny all except security_storage', () => {
|
||||
const defaults = buildDeniedDefaults();
|
||||
expect(defaults.ad_storage).toBe('denied');
|
||||
expect(defaults.ad_user_data).toBe('denied');
|
||||
expect(defaults.ad_personalization).toBe('denied');
|
||||
expect(defaults.analytics_storage).toBe('denied');
|
||||
expect(defaults.functionality_storage).toBe('denied');
|
||||
expect(defaults.personalization_storage).toBe('denied');
|
||||
expect(defaults.security_storage).toBe('granted');
|
||||
});
|
||||
|
||||
it('should return all 7 consent types', () => {
|
||||
const defaults = buildDeniedDefaults();
|
||||
const keys = Object.keys(defaults);
|
||||
expect(keys).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGcmStateFromCategories', () => {
|
||||
it('should grant analytics_storage for analytics category', () => {
|
||||
const state = buildGcmStateFromCategories(['necessary', 'analytics']);
|
||||
expect(state.analytics_storage).toBe('granted');
|
||||
expect(state.ad_storage).toBe('denied');
|
||||
});
|
||||
|
||||
it('should grant ad types for marketing category', () => {
|
||||
const state = buildGcmStateFromCategories(['necessary', 'marketing']);
|
||||
expect(state.ad_storage).toBe('granted');
|
||||
expect(state.ad_user_data).toBe('granted');
|
||||
expect(state.ad_personalization).toBe('granted');
|
||||
expect(state.analytics_storage).toBe('denied');
|
||||
});
|
||||
|
||||
it('should grant functionality for functional category', () => {
|
||||
const state = buildGcmStateFromCategories(['necessary', 'functional']);
|
||||
expect(state.functionality_storage).toBe('granted');
|
||||
expect(state.personalization_storage).toBe('granted');
|
||||
});
|
||||
|
||||
it('should grant personalization_storage for personalisation category', () => {
|
||||
const state = buildGcmStateFromCategories(['necessary', 'personalisation']);
|
||||
expect(state.personalization_storage).toBe('granted');
|
||||
expect(state.functionality_storage).toBe('denied');
|
||||
});
|
||||
|
||||
it('should grant all for all categories', () => {
|
||||
const state = buildGcmStateFromCategories([
|
||||
'necessary', 'functional', 'analytics', 'marketing', 'personalisation',
|
||||
]);
|
||||
expect(state.analytics_storage).toBe('granted');
|
||||
expect(state.ad_storage).toBe('granted');
|
||||
expect(state.ad_user_data).toBe('granted');
|
||||
expect(state.ad_personalization).toBe('granted');
|
||||
expect(state.functionality_storage).toBe('granted');
|
||||
expect(state.personalization_storage).toBe('granted');
|
||||
expect(state.security_storage).toBe('granted');
|
||||
});
|
||||
|
||||
it('should deny all for only necessary', () => {
|
||||
const state = buildGcmStateFromCategories(['necessary']);
|
||||
expect(state.analytics_storage).toBe('denied');
|
||||
expect(state.ad_storage).toBe('denied');
|
||||
expect(state.functionality_storage).toBe('denied');
|
||||
expect(state.security_storage).toBe('granted');
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const state = buildGcmStateFromCategories([]);
|
||||
expect(state.analytics_storage).toBe('denied');
|
||||
expect(state.ad_storage).toBe('denied');
|
||||
expect(state.security_storage).toBe('granted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setGcmDefaults', () => {
|
||||
it('should create dataLayer if it does not exist', () => {
|
||||
// @ts-expect-error — simulating no dataLayer
|
||||
delete window.dataLayer;
|
||||
setGcmDefaults(buildDeniedDefaults());
|
||||
expect(window.dataLayer).toBeDefined();
|
||||
expect(Array.isArray(window.dataLayer)).toBe(true);
|
||||
});
|
||||
|
||||
it('should push consent default command to dataLayer', () => {
|
||||
setGcmDefaults(buildDeniedDefaults());
|
||||
// The gtag function pushes arguments objects to dataLayer
|
||||
expect(window.dataLayer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create gtag function if not present', () => {
|
||||
setGcmDefaults(buildDeniedDefaults());
|
||||
expect(typeof window.gtag).toBe('function');
|
||||
});
|
||||
|
||||
it('should include wait_for_update parameter', () => {
|
||||
setGcmDefaults(buildDeniedDefaults());
|
||||
// The first entry should be the consent default call
|
||||
// gtag pushes `arguments` objects; check the dataLayer
|
||||
const entry = window.dataLayer[0] as { [key: number]: unknown };
|
||||
// Arguments object: [0]='consent', [1]='default', [2]={...}
|
||||
expect(entry[0]).toBe('consent');
|
||||
expect(entry[1]).toBe('default');
|
||||
const consentObj = entry[2] as Record<string, unknown>;
|
||||
expect(consentObj.wait_for_update).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateGcm', () => {
|
||||
it('should push consent update command to dataLayer', () => {
|
||||
// Initialise gtag first
|
||||
setGcmDefaults(buildDeniedDefaults());
|
||||
const lengthBefore = window.dataLayer.length;
|
||||
|
||||
updateGcm({ analytics_storage: 'granted' });
|
||||
|
||||
expect(window.dataLayer.length).toBe(lengthBefore + 1);
|
||||
const entry = window.dataLayer[window.dataLayer.length - 1] as { [key: number]: unknown };
|
||||
expect(entry[0]).toBe('consent');
|
||||
expect(entry[1]).toBe('update');
|
||||
});
|
||||
|
||||
it('should work without prior setGcmDefaults call', () => {
|
||||
updateGcm({ ad_storage: 'granted' });
|
||||
expect(window.dataLayer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should pass the state object correctly', () => {
|
||||
setGcmDefaults(buildDeniedDefaults());
|
||||
const state = { analytics_storage: 'granted' as const, ad_storage: 'denied' as const };
|
||||
updateGcm(state);
|
||||
|
||||
const entry = window.dataLayer[window.dataLayer.length - 1] as { [key: number]: unknown };
|
||||
const consentObj = entry[2] as Record<string, string>;
|
||||
expect(consentObj.analytics_storage).toBe('granted');
|
||||
expect(consentObj.ad_storage).toBe('denied');
|
||||
});
|
||||
});
|
||||
});
|
||||
226
apps/banner/src/__tests__/gpc.test.ts
Normal file
226
apps/banner/src/__tests__/gpc.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Tests for GPC signal detection and jurisdiction-aware auto-opt-out.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
type GpcConfig,
|
||||
type GpcResult,
|
||||
isGpcEnabled,
|
||||
shouldHonourGpc,
|
||||
evaluateGpc,
|
||||
DEFAULT_GPC_JURISDICTIONS,
|
||||
GPC_OPTOUT_CATEGORIES,
|
||||
GPC_ACCEPTED_CATEGORIES,
|
||||
} from '../gpc';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function makeGpcConfig(overrides: Partial<GpcConfig> = {}): GpcConfig {
|
||||
return {
|
||||
gpc_enabled: true,
|
||||
gpc_jurisdictions: [],
|
||||
gpc_global_honour: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Set or clear the GPC signal on navigator. */
|
||||
function setGpc(value: boolean | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete (navigator as { globalPrivacyControl?: boolean }).globalPrivacyControl;
|
||||
} else {
|
||||
Object.defineProperty(navigator, 'globalPrivacyControl', {
|
||||
value,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GPC Signal Detection', () => {
|
||||
afterEach(() => {
|
||||
setGpc(undefined);
|
||||
});
|
||||
|
||||
// ── isGpcEnabled ──────────────────────────────────────────────────
|
||||
|
||||
describe('isGpcEnabled', () => {
|
||||
it('returns true when navigator.globalPrivacyControl is true', () => {
|
||||
setGpc(true);
|
||||
expect(isGpcEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when navigator.globalPrivacyControl is false', () => {
|
||||
setGpc(false);
|
||||
expect(isGpcEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when navigator.globalPrivacyControl is undefined', () => {
|
||||
setGpc(undefined);
|
||||
expect(isGpcEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── shouldHonourGpc ───────────────────────────────────────────────
|
||||
|
||||
describe('shouldHonourGpc', () => {
|
||||
it('returns false when gpc_enabled is false', () => {
|
||||
const config = makeGpcConfig({ gpc_enabled: false });
|
||||
expect(shouldHonourGpc('US-CA', config)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when gpc_global_honour is true regardless of region', () => {
|
||||
const config = makeGpcConfig({ gpc_global_honour: true });
|
||||
expect(shouldHonourGpc('DE', config)).toBe(true);
|
||||
expect(shouldHonourGpc(null, config)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for default jurisdiction US-CA', () => {
|
||||
const config = makeGpcConfig();
|
||||
expect(shouldHonourGpc('US-CA', config)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for default jurisdiction US-CO', () => {
|
||||
const config = makeGpcConfig();
|
||||
expect(shouldHonourGpc('US-CO', config)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for default jurisdiction US-CT', () => {
|
||||
const config = makeGpcConfig();
|
||||
expect(shouldHonourGpc('US-CT', config)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for default jurisdiction US-TX', () => {
|
||||
const config = makeGpcConfig();
|
||||
expect(shouldHonourGpc('US-TX', config)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for default jurisdiction US-MT', () => {
|
||||
const config = makeGpcConfig();
|
||||
expect(shouldHonourGpc('US-MT', config)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-GPC jurisdiction', () => {
|
||||
const config = makeGpcConfig();
|
||||
expect(shouldHonourGpc('US-NY', config)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for null region', () => {
|
||||
const config = makeGpcConfig();
|
||||
expect(shouldHonourGpc(null, config)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses custom jurisdictions when provided', () => {
|
||||
const config = makeGpcConfig({
|
||||
gpc_jurisdictions: ['GB', 'DE'],
|
||||
});
|
||||
expect(shouldHonourGpc('GB', config)).toBe(true);
|
||||
expect(shouldHonourGpc('DE', config)).toBe(true);
|
||||
expect(shouldHonourGpc('US-CA', config)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── evaluateGpc ───────────────────────────────────────────────────
|
||||
|
||||
describe('evaluateGpc', () => {
|
||||
it('returns detected=false when GPC is not set', () => {
|
||||
setGpc(undefined);
|
||||
const config = makeGpcConfig();
|
||||
const result = evaluateGpc(config, 'US-CA');
|
||||
|
||||
expect(result.detected).toBe(false);
|
||||
expect(result.honoured).toBe(false);
|
||||
});
|
||||
|
||||
it('returns detected=true, honoured=true in California', () => {
|
||||
setGpc(true);
|
||||
const config = makeGpcConfig();
|
||||
const result = evaluateGpc(config, 'US-CA');
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.honoured).toBe(true);
|
||||
expect(result.region).toBe('US-CA');
|
||||
});
|
||||
|
||||
it('returns detected=true, honoured=false in non-GPC state', () => {
|
||||
setGpc(true);
|
||||
const config = makeGpcConfig();
|
||||
const result = evaluateGpc(config, 'US-NY');
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.honoured).toBe(false);
|
||||
});
|
||||
|
||||
it('returns detected=true, honoured=false when gpc_enabled is false', () => {
|
||||
setGpc(true);
|
||||
const config = makeGpcConfig({ gpc_enabled: false });
|
||||
const result = evaluateGpc(config, 'US-CA');
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.honoured).toBe(false);
|
||||
});
|
||||
|
||||
it('returns honoured=true with gpc_global_honour regardless of region', () => {
|
||||
setGpc(true);
|
||||
const config = makeGpcConfig({ gpc_global_honour: true });
|
||||
const result = evaluateGpc(config, 'FR');
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.honoured).toBe(true);
|
||||
});
|
||||
|
||||
it('passes null region through', () => {
|
||||
setGpc(true);
|
||||
const config = makeGpcConfig();
|
||||
const result = evaluateGpc(config, null);
|
||||
|
||||
expect(result.region).toBeNull();
|
||||
expect(result.honoured).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults region to null when not provided', () => {
|
||||
setGpc(true);
|
||||
const config = makeGpcConfig({ gpc_global_honour: true });
|
||||
const result = evaluateGpc(config);
|
||||
|
||||
expect(result.region).toBeNull();
|
||||
expect(result.honoured).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────
|
||||
|
||||
describe('constants', () => {
|
||||
it('DEFAULT_GPC_JURISDICTIONS includes all five required states', () => {
|
||||
expect(DEFAULT_GPC_JURISDICTIONS).toContain('US-CA');
|
||||
expect(DEFAULT_GPC_JURISDICTIONS).toContain('US-CO');
|
||||
expect(DEFAULT_GPC_JURISDICTIONS).toContain('US-CT');
|
||||
expect(DEFAULT_GPC_JURISDICTIONS).toContain('US-TX');
|
||||
expect(DEFAULT_GPC_JURISDICTIONS).toContain('US-MT');
|
||||
expect(DEFAULT_GPC_JURISDICTIONS).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('GPC_OPTOUT_CATEGORIES includes marketing and personalisation', () => {
|
||||
expect(GPC_OPTOUT_CATEGORIES).toContain('marketing');
|
||||
expect(GPC_OPTOUT_CATEGORIES).toContain('personalisation');
|
||||
});
|
||||
|
||||
it('GPC_ACCEPTED_CATEGORIES includes necessary, functional, analytics', () => {
|
||||
expect(GPC_ACCEPTED_CATEGORIES).toContain('necessary');
|
||||
expect(GPC_ACCEPTED_CATEGORIES).toContain('functional');
|
||||
expect(GPC_ACCEPTED_CATEGORIES).toContain('analytics');
|
||||
});
|
||||
|
||||
it('GPC opt-out and accepted categories cover all categories', () => {
|
||||
const all = [...GPC_ACCEPTED_CATEGORIES, ...GPC_OPTOUT_CATEGORIES];
|
||||
expect(all).toContain('necessary');
|
||||
expect(all).toContain('functional');
|
||||
expect(all).toContain('analytics');
|
||||
expect(all).toContain('marketing');
|
||||
expect(all).toContain('personalisation');
|
||||
});
|
||||
});
|
||||
});
|
||||
501
apps/banner/src/__tests__/gpp-api.test.ts
Normal file
501
apps/banner/src/__tests__/gpp-api.test.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* Tests for the GPP CMP API (__gpp() global function).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
installGppApi,
|
||||
removeGppApi,
|
||||
updateGppConsent,
|
||||
setGppDisplayStatus,
|
||||
setGppSignalStatus,
|
||||
getGppString,
|
||||
isGppApiInstalled,
|
||||
type GppPingReturn,
|
||||
type GppData,
|
||||
type GppEventData,
|
||||
type GppApiCallback,
|
||||
} from '../gpp-api';
|
||||
import {
|
||||
type GppString,
|
||||
createDefaultSectionData,
|
||||
US_NATIONAL,
|
||||
US_CALIFORNIA,
|
||||
SECTION_REGISTRY,
|
||||
} from '../gpp';
|
||||
|
||||
// Section IDs (US_NATIONAL and US_CALIFORNIA are SectionDef objects, not numbers)
|
||||
const US_NATIONAL_ID = US_NATIONAL.id; // 7
|
||||
const US_CALIFORNIA_ID = US_CALIFORNIA.id; // 8
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Call __gpp and return the callback args as a promise. */
|
||||
function callGpp(
|
||||
command: string,
|
||||
parameter?: unknown,
|
||||
): Promise<{ data: unknown; success: boolean }> {
|
||||
return new Promise((resolve) => {
|
||||
window.__gpp!(command, (data: unknown, success: boolean) => {
|
||||
resolve({ data, success });
|
||||
}, parameter);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a minimal GppString for testing. */
|
||||
function buildTestGpp(sectionIds: number[] = [US_NATIONAL_ID]): GppString {
|
||||
const sections = new Map<number, { data: Record<string, number | number[]> }>();
|
||||
for (const id of sectionIds) {
|
||||
const def = SECTION_REGISTRY.get(id);
|
||||
if (def) {
|
||||
const data = createDefaultSectionData(def);
|
||||
sections.set(id, { data });
|
||||
}
|
||||
}
|
||||
return {
|
||||
header: {
|
||||
version: 1,
|
||||
sectionIds,
|
||||
applicableSections: sectionIds,
|
||||
},
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GPP CMP API', () => {
|
||||
beforeEach(() => {
|
||||
removeGppApi();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
removeGppApi();
|
||||
});
|
||||
|
||||
// ── Installation ──────────────────────────────────────────────────
|
||||
|
||||
describe('installGppApi / removeGppApi', () => {
|
||||
it('installs __gpp on window', () => {
|
||||
expect(window.__gpp).toBeUndefined();
|
||||
installGppApi(42, ['usnat']);
|
||||
expect(typeof window.__gpp).toBe('function');
|
||||
expect(isGppApiInstalled()).toBe(true);
|
||||
});
|
||||
|
||||
it('removes __gpp from window', () => {
|
||||
installGppApi(42);
|
||||
removeGppApi();
|
||||
expect(window.__gpp).toBeUndefined();
|
||||
expect(isGppApiInstalled()).toBe(false);
|
||||
});
|
||||
|
||||
it('clears __gppQueue on remove', () => {
|
||||
(window as { __gppQueue?: unknown[] }).__gppQueue = [['ping', vi.fn()]];
|
||||
installGppApi(42);
|
||||
removeGppApi();
|
||||
expect((window as { __gppQueue?: unknown[] }).__gppQueue).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── ping command ──────────────────────────────────────────────────
|
||||
|
||||
describe('ping command', () => {
|
||||
it('returns CMP metadata', async () => {
|
||||
installGppApi(42, ['usnat', 'usca']);
|
||||
const { data, success } = await callGpp('ping');
|
||||
expect(success).toBe(true);
|
||||
|
||||
const ping = data as GppPingReturn;
|
||||
expect(ping.gppVersion).toBe('1.1');
|
||||
expect(ping.cmpStatus).toBe('loaded');
|
||||
expect(ping.cmpDisplayStatus).toBe('hidden');
|
||||
expect(ping.signalStatus).toBe('not ready');
|
||||
expect(ping.supportedAPIs).toEqual(['usnat', 'usca']);
|
||||
expect(ping.cmpId).toBe(42);
|
||||
expect(ping.gppString).toBe('');
|
||||
expect(ping.applicableSections).toEqual([]);
|
||||
});
|
||||
|
||||
it('reflects updated display status', async () => {
|
||||
installGppApi(1);
|
||||
setGppDisplayStatus('visible');
|
||||
const { data } = await callGpp('ping');
|
||||
expect((data as GppPingReturn).cmpDisplayStatus).toBe('visible');
|
||||
});
|
||||
|
||||
it('reflects updated signal status', async () => {
|
||||
installGppApi(1);
|
||||
setGppSignalStatus('ready');
|
||||
const { data } = await callGpp('ping');
|
||||
expect((data as GppPingReturn).signalStatus).toBe('ready');
|
||||
});
|
||||
|
||||
it('includes GPP string after consent update', async () => {
|
||||
installGppApi(1, ['usnat']);
|
||||
const gpp = buildTestGpp();
|
||||
const encoded = updateGppConsent(gpp);
|
||||
expect(encoded).toBeTruthy();
|
||||
|
||||
const { data } = await callGpp('ping');
|
||||
expect((data as GppPingReturn).gppString).toBe(encoded);
|
||||
expect((data as GppPingReturn).applicableSections).toEqual([US_NATIONAL_ID]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getGPPData command ────────────────────────────────────────────
|
||||
|
||||
describe('getGPPData command', () => {
|
||||
it('returns empty data before consent', async () => {
|
||||
installGppApi(1);
|
||||
const { data, success } = await callGpp('getGPPData');
|
||||
expect(success).toBe(true);
|
||||
|
||||
const gppData = data as GppData;
|
||||
expect(gppData.gppString).toBe('');
|
||||
expect(gppData.applicableSections).toEqual([]);
|
||||
expect(gppData.parsedSections).toEqual({});
|
||||
});
|
||||
|
||||
it('returns populated data after consent update', async () => {
|
||||
installGppApi(1, ['usnat']);
|
||||
const gpp = buildTestGpp();
|
||||
updateGppConsent(gpp);
|
||||
|
||||
const { data } = await callGpp('getGPPData');
|
||||
const gppData = data as GppData;
|
||||
expect(gppData.gppString).toBeTruthy();
|
||||
expect(gppData.applicableSections).toEqual([US_NATIONAL_ID]);
|
||||
expect(gppData.parsedSections[US_NATIONAL_ID]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getSection command ────────────────────────────────────────────
|
||||
|
||||
describe('getSection command', () => {
|
||||
it('returns null for missing prefix', async () => {
|
||||
installGppApi(1);
|
||||
const { data, success } = await callGpp('getSection', 'usnat');
|
||||
expect(success).toBe(false);
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no parameter given', async () => {
|
||||
installGppApi(1);
|
||||
const { data, success } = await callGpp('getSection');
|
||||
expect(success).toBe(false);
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
it('returns section data by API prefix', async () => {
|
||||
installGppApi(1, ['usnat']);
|
||||
const gpp = buildTestGpp();
|
||||
updateGppConsent(gpp);
|
||||
|
||||
const { data, success } = await callGpp('getSection', 'usnat');
|
||||
expect(success).toBe(true);
|
||||
expect(data).toBeDefined();
|
||||
expect((data as Record<string, unknown>).Version).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns null for unregistered prefix', async () => {
|
||||
installGppApi(1);
|
||||
updateGppConsent(buildTestGpp());
|
||||
|
||||
const { data, success } = await callGpp('getSection', 'nonexistent');
|
||||
expect(success).toBe(false);
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── hasSection command ────────────────────────────────────────────
|
||||
|
||||
describe('hasSection command', () => {
|
||||
it('returns false when section not present', async () => {
|
||||
installGppApi(1);
|
||||
const { data, success } = await callGpp('hasSection', 'usnat');
|
||||
expect(success).toBe(true);
|
||||
expect(data).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when section present', async () => {
|
||||
installGppApi(1, ['usnat']);
|
||||
updateGppConsent(buildTestGpp());
|
||||
|
||||
const { data, success } = await callGpp('hasSection', 'usnat');
|
||||
expect(success).toBe(true);
|
||||
expect(data).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for empty parameter', async () => {
|
||||
installGppApi(1);
|
||||
const { data, success } = await callGpp('hasSection');
|
||||
expect(success).toBe(true);
|
||||
expect(data).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── addEventListener / removeEventListener ────────────────────────
|
||||
|
||||
describe('addEventListener', () => {
|
||||
it('registers a listener and fires immediately', async () => {
|
||||
installGppApi(1, ['usnat']);
|
||||
|
||||
const { data, success } = await callGpp('addEventListener');
|
||||
expect(success).toBe(true);
|
||||
|
||||
const event = data as GppEventData;
|
||||
expect(event.eventName).toBe('listenerRegistered');
|
||||
expect(event.listenerId).toBe(1);
|
||||
expect(event.pingData.cmpStatus).toBe('loaded');
|
||||
});
|
||||
|
||||
it('assigns unique listener IDs', async () => {
|
||||
installGppApi(1);
|
||||
|
||||
const { data: d1 } = await callGpp('addEventListener');
|
||||
const { data: d2 } = await callGpp('addEventListener');
|
||||
|
||||
expect((d1 as GppEventData).listenerId).toBe(1);
|
||||
expect((d2 as GppEventData).listenerId).toBe(2);
|
||||
});
|
||||
|
||||
it('notifies listeners on consent update', () => {
|
||||
installGppApi(1, ['usnat']);
|
||||
|
||||
const events: GppEventData[] = [];
|
||||
window.__gpp!('addEventListener', (data: unknown) => {
|
||||
events.push(data as GppEventData);
|
||||
});
|
||||
|
||||
// First event is listenerRegistered
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].eventName).toBe('listenerRegistered');
|
||||
|
||||
// Update consent — should fire signalStatus event
|
||||
updateGppConsent(buildTestGpp());
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[1].eventName).toBe('signalStatus');
|
||||
expect(events[1].data).toBeDefined();
|
||||
expect((events[1].data as GppData).gppString).toBeTruthy();
|
||||
});
|
||||
|
||||
it('swallows errors thrown by listeners', () => {
|
||||
installGppApi(1);
|
||||
|
||||
const throwingCallback: GppApiCallback = () => {
|
||||
throw new Error('Listener error');
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
window.__gpp!('addEventListener', throwingCallback);
|
||||
}).not.toThrow();
|
||||
|
||||
// Should not throw during consent update either
|
||||
expect(() => {
|
||||
updateGppConsent(buildTestGpp());
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeEventListener', () => {
|
||||
it('removes an existing listener', async () => {
|
||||
installGppApi(1);
|
||||
|
||||
// Add listener
|
||||
const { data } = await callGpp('addEventListener');
|
||||
const listenerId = (data as GppEventData).listenerId;
|
||||
|
||||
// Remove it
|
||||
const { data: removed, success } = await callGpp('removeEventListener', listenerId);
|
||||
expect(success).toBe(true);
|
||||
expect(removed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-existent listener', async () => {
|
||||
installGppApi(1);
|
||||
const { data, success } = await callGpp('removeEventListener', 999);
|
||||
expect(success).toBe(false);
|
||||
expect(data).toBe(false);
|
||||
});
|
||||
|
||||
it('stops notifications after removal', () => {
|
||||
installGppApi(1, ['usnat']);
|
||||
|
||||
const events: GppEventData[] = [];
|
||||
let listenerId: number;
|
||||
|
||||
window.__gpp!('addEventListener', (data: unknown) => {
|
||||
const event = data as GppEventData;
|
||||
events.push(event);
|
||||
listenerId = event.listenerId;
|
||||
});
|
||||
|
||||
// Remove the listener
|
||||
window.__gpp!('removeEventListener', (_d: unknown, _s: boolean) => {}, listenerId!);
|
||||
|
||||
// Update consent — should NOT fire to removed listener
|
||||
const eventsBefore = events.length;
|
||||
updateGppConsent(buildTestGpp());
|
||||
|
||||
expect(events.length).toBe(eventsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Unknown commands ──────────────────────────────────────────────
|
||||
|
||||
describe('unknown commands', () => {
|
||||
it('returns false for unknown commands', async () => {
|
||||
installGppApi(1);
|
||||
const { data, success } = await callGpp('unknownCommand');
|
||||
expect(success).toBe(false);
|
||||
expect(data).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Uninitialised state ───────────────────────────────────────────
|
||||
|
||||
describe('uninitialised state', () => {
|
||||
it('returns false when API not installed', () => {
|
||||
// Manually set __gpp to the handler without initialising state
|
||||
// by calling removeGppApi first to clear state, then manually assigning
|
||||
removeGppApi();
|
||||
|
||||
// Directly import and call the handler — simulate calling before install
|
||||
let callbackData: unknown;
|
||||
let callbackSuccess: boolean | undefined;
|
||||
|
||||
// We can't call __gpp because it's removed, but we can test via getGppString
|
||||
expect(getGppString()).toBe('');
|
||||
expect(isGppApiInstalled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateGppConsent ──────────────────────────────────────────────
|
||||
|
||||
describe('updateGppConsent', () => {
|
||||
it('returns the encoded GPP string', () => {
|
||||
installGppApi(1, ['usnat']);
|
||||
const gpp = buildTestGpp();
|
||||
const result = updateGppConsent(gpp);
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe('string');
|
||||
// GPP strings start with 'D' (header prefix)
|
||||
expect(result.startsWith('D')).toBe(true);
|
||||
});
|
||||
|
||||
it('updates the gppString accessible via getGppString', () => {
|
||||
installGppApi(1);
|
||||
expect(getGppString()).toBe('');
|
||||
|
||||
updateGppConsent(buildTestGpp());
|
||||
expect(getGppString()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sets signalStatus to ready', async () => {
|
||||
installGppApi(1);
|
||||
updateGppConsent(buildTestGpp());
|
||||
|
||||
const { data } = await callGpp('ping');
|
||||
expect((data as GppPingReturn).signalStatus).toBe('ready');
|
||||
});
|
||||
|
||||
it('works with multiple sections', () => {
|
||||
installGppApi(1, ['usnat', 'usca']);
|
||||
const gpp = buildTestGpp([US_NATIONAL_ID, US_CALIFORNIA_ID]);
|
||||
const result = updateGppConsent(gpp);
|
||||
expect(result).toBeTruthy();
|
||||
|
||||
// Should contain ~ separator for sections
|
||||
expect(result).toContain('~');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Queue processing ──────────────────────────────────────────────
|
||||
|
||||
describe('queue processing', () => {
|
||||
it('processes queued calls on install', () => {
|
||||
// Set up a queue before installing
|
||||
const results: Array<{ data: unknown; success: boolean }> = [];
|
||||
const cb = (data: unknown, success: boolean) => {
|
||||
results.push({ data, success });
|
||||
};
|
||||
|
||||
(window as { __gppQueue?: unknown[] }).__gppQueue = [
|
||||
['ping', cb],
|
||||
];
|
||||
|
||||
installGppApi(1, ['usnat']);
|
||||
|
||||
// The queued ping should have been processed
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].success).toBe(true);
|
||||
expect((results[0].data as GppPingReturn).cmpStatus).toBe('loaded');
|
||||
});
|
||||
|
||||
it('clears the queue after processing', () => {
|
||||
(window as { __gppQueue?: unknown[] }).__gppQueue = [
|
||||
['ping', vi.fn()],
|
||||
];
|
||||
|
||||
installGppApi(1);
|
||||
|
||||
expect((window as { __gppQueue?: unknown[] }).__gppQueue).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Coexistence with __tcfapi ─────────────────────────────────────
|
||||
|
||||
describe('coexistence', () => {
|
||||
it('does not interfere with other window globals', () => {
|
||||
// Set a mock __tcfapi
|
||||
const mockTcf = vi.fn();
|
||||
(window as { __tcfapi?: unknown }).__tcfapi = mockTcf;
|
||||
|
||||
installGppApi(1);
|
||||
|
||||
// __tcfapi should still be intact
|
||||
expect((window as { __tcfapi?: unknown }).__tcfapi).toBe(mockTcf);
|
||||
|
||||
removeGppApi();
|
||||
|
||||
// __tcfapi should still be intact after removal
|
||||
expect((window as { __tcfapi?: unknown }).__tcfapi).toBe(mockTcf);
|
||||
|
||||
// Clean up
|
||||
delete (window as { __tcfapi?: unknown }).__tcfapi;
|
||||
});
|
||||
});
|
||||
|
||||
// ── setGppDisplayStatus / setGppSignalStatus ──────────────────────
|
||||
|
||||
describe('status setters', () => {
|
||||
it('setGppDisplayStatus updates display status', async () => {
|
||||
installGppApi(1);
|
||||
setGppDisplayStatus('visible');
|
||||
|
||||
const { data } = await callGpp('ping');
|
||||
expect((data as GppPingReturn).cmpDisplayStatus).toBe('visible');
|
||||
|
||||
setGppDisplayStatus('disabled');
|
||||
const { data: d2 } = await callGpp('ping');
|
||||
expect((d2 as GppPingReturn).cmpDisplayStatus).toBe('disabled');
|
||||
});
|
||||
|
||||
it('setGppSignalStatus updates signal status', async () => {
|
||||
installGppApi(1);
|
||||
setGppSignalStatus('ready');
|
||||
|
||||
const { data } = await callGpp('ping');
|
||||
expect((data as GppPingReturn).signalStatus).toBe('ready');
|
||||
});
|
||||
|
||||
it('no-ops when API not installed', () => {
|
||||
// Should not throw
|
||||
expect(() => setGppDisplayStatus('visible')).not.toThrow();
|
||||
expect(() => setGppSignalStatus('ready')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
926
apps/banner/src/__tests__/gpp.test.ts
Normal file
926
apps/banner/src/__tests__/gpp.test.ts
Normal file
@@ -0,0 +1,926 @@
|
||||
/**
|
||||
* Tests for the IAB GPP string encoder/decoder.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { BitWriter, BitReader, bytesToBase64url } from '../tcf';
|
||||
import {
|
||||
fibonacciEncode,
|
||||
fibonacciDecode,
|
||||
encodeGppHeader,
|
||||
decodeGppHeader,
|
||||
encodeSectionCore,
|
||||
decodeSectionCore,
|
||||
encodeGpcSubsection,
|
||||
decodeGpcSubsection,
|
||||
encodeSection,
|
||||
decodeSection,
|
||||
encodeGppString,
|
||||
decodeGppString,
|
||||
createDefaultSectionData,
|
||||
getSectionByPrefix,
|
||||
registerSection,
|
||||
US_NATIONAL,
|
||||
US_CALIFORNIA,
|
||||
US_VIRGINIA,
|
||||
US_COLORADO,
|
||||
US_CONNECTICUT,
|
||||
US_FLORIDA,
|
||||
SECTION_REGISTRY,
|
||||
GppFieldValue,
|
||||
type SectionDef,
|
||||
type SectionData,
|
||||
type GppHeader,
|
||||
type GppString,
|
||||
} from '../gpp';
|
||||
|
||||
// ── Fibonacci coding ─────────────────────────────────────────────────
|
||||
|
||||
describe('Fibonacci coding', () => {
|
||||
it('encodes and decodes 1', () => {
|
||||
const writer = new BitWriter();
|
||||
fibonacciEncode(writer, 1);
|
||||
const bytes = writer.toBytes();
|
||||
const reader = new BitReader(bytes);
|
||||
expect(fibonacciDecode(reader)).toBe(1);
|
||||
});
|
||||
|
||||
it('encodes and decodes 2', () => {
|
||||
const writer = new BitWriter();
|
||||
fibonacciEncode(writer, 2);
|
||||
const bytes = writer.toBytes();
|
||||
const reader = new BitReader(bytes);
|
||||
expect(fibonacciDecode(reader)).toBe(2);
|
||||
});
|
||||
|
||||
it('encodes and decodes small integers (1–20)', () => {
|
||||
for (let n = 1; n <= 20; n++) {
|
||||
const writer = new BitWriter();
|
||||
fibonacciEncode(writer, n);
|
||||
const bytes = writer.toBytes();
|
||||
const reader = new BitReader(bytes);
|
||||
expect(fibonacciDecode(reader)).toBe(n);
|
||||
}
|
||||
});
|
||||
|
||||
it('encodes and decodes larger integers', () => {
|
||||
const values = [21, 34, 55, 100, 233, 500, 987, 1000];
|
||||
for (const n of values) {
|
||||
const writer = new BitWriter();
|
||||
fibonacciEncode(writer, n);
|
||||
const bytes = writer.toBytes();
|
||||
const reader = new BitReader(bytes);
|
||||
expect(fibonacciDecode(reader)).toBe(n);
|
||||
}
|
||||
});
|
||||
|
||||
it('encodes multiple integers sequentially', () => {
|
||||
const values = [7, 3, 11, 1, 8];
|
||||
const writer = new BitWriter();
|
||||
for (const v of values) {
|
||||
fibonacciEncode(writer, v);
|
||||
}
|
||||
const bytes = writer.toBytes();
|
||||
const reader = new BitReader(bytes);
|
||||
for (const v of values) {
|
||||
expect(fibonacciDecode(reader)).toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws on zero or negative values', () => {
|
||||
const writer = new BitWriter();
|
||||
expect(() => fibonacciEncode(writer, 0)).toThrow('positive integer');
|
||||
expect(() => fibonacciEncode(writer, -1)).toThrow('positive integer');
|
||||
});
|
||||
|
||||
it('produces correct bit pattern for value 1 (11)', () => {
|
||||
const writer = new BitWriter();
|
||||
fibonacciEncode(writer, 1);
|
||||
// Value 1: position 0 set (F(2)=1), then terminator → bits: 1,1
|
||||
const bytes = writer.toBytes();
|
||||
// First byte: 11xxxxxx → 0b11000000 = 192
|
||||
expect(bytes[0] & 0xc0).toBe(0xc0);
|
||||
});
|
||||
|
||||
it('produces correct bit pattern for value 2 (011)', () => {
|
||||
const writer = new BitWriter();
|
||||
fibonacciEncode(writer, 2);
|
||||
// Value 2: position 1 set (F(3)=2), then terminator → bits: 0,1,1
|
||||
const bytes = writer.toBytes();
|
||||
// First byte: 011xxxxx → 0b01100000 = 96
|
||||
expect(bytes[0] & 0xe0).toBe(0x60);
|
||||
});
|
||||
|
||||
it('produces correct bit pattern for value 4 (1011)', () => {
|
||||
const writer = new BitWriter();
|
||||
fibonacciEncode(writer, 4);
|
||||
// Value 4 = F(2)+F(4) = 1+3: positions 0,2 set, then terminator → bits: 1,0,1,1
|
||||
const bytes = writer.toBytes();
|
||||
// First byte: 1011xxxx → 0b10110000 = 176
|
||||
expect(bytes[0] & 0xf0).toBe(0xb0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GPP Header ───────────────────────────────────────────────────────
|
||||
|
||||
describe('GPP header', () => {
|
||||
it('encodes and decodes an empty header', () => {
|
||||
const header: GppHeader = {
|
||||
version: 1,
|
||||
sectionIds: [],
|
||||
applicableSections: [],
|
||||
};
|
||||
const encoded = encodeGppHeader(header);
|
||||
const decoded = decodeGppHeader(encoded);
|
||||
|
||||
expect(decoded.version).toBe(1);
|
||||
expect(decoded.sectionIds).toEqual([]);
|
||||
expect(decoded.applicableSections).toEqual([]);
|
||||
});
|
||||
|
||||
it('encodes and decodes a header with a single section', () => {
|
||||
const header: GppHeader = {
|
||||
version: 1,
|
||||
sectionIds: [7],
|
||||
applicableSections: [7],
|
||||
};
|
||||
const encoded = encodeGppHeader(header);
|
||||
const decoded = decodeGppHeader(encoded);
|
||||
|
||||
expect(decoded.version).toBe(1);
|
||||
expect(decoded.sectionIds).toEqual([7]);
|
||||
expect(decoded.applicableSections).toEqual([7]);
|
||||
});
|
||||
|
||||
it('encodes and decodes a header with multiple sections', () => {
|
||||
const header: GppHeader = {
|
||||
version: 1,
|
||||
sectionIds: [7, 8, 10],
|
||||
applicableSections: [7, 8],
|
||||
};
|
||||
const encoded = encodeGppHeader(header);
|
||||
const decoded = decodeGppHeader(encoded);
|
||||
|
||||
expect(decoded.version).toBe(1);
|
||||
expect(decoded.sectionIds).toEqual([7, 8, 10]);
|
||||
expect(decoded.applicableSections).toEqual([7, 8]);
|
||||
});
|
||||
|
||||
it('encodes consecutive section IDs as ranges', () => {
|
||||
const header: GppHeader = {
|
||||
version: 1,
|
||||
sectionIds: [7, 8, 9, 10, 11],
|
||||
applicableSections: [7, 8, 9, 10, 11],
|
||||
};
|
||||
const encoded = encodeGppHeader(header);
|
||||
const decoded = decodeGppHeader(encoded);
|
||||
|
||||
expect(decoded.sectionIds).toEqual([7, 8, 9, 10, 11]);
|
||||
expect(decoded.applicableSections).toEqual([7, 8, 9, 10, 11]);
|
||||
});
|
||||
|
||||
it('handles mixed consecutive and non-consecutive IDs', () => {
|
||||
const header: GppHeader = {
|
||||
version: 1,
|
||||
sectionIds: [7, 8, 9, 11, 14],
|
||||
applicableSections: [7],
|
||||
};
|
||||
const encoded = encodeGppHeader(header);
|
||||
const decoded = decodeGppHeader(encoded);
|
||||
|
||||
expect(decoded.sectionIds).toEqual([7, 8, 9, 11, 14]);
|
||||
expect(decoded.applicableSections).toEqual([7]);
|
||||
});
|
||||
|
||||
it('throws on invalid header type', () => {
|
||||
// Craft a header with type = 0 instead of 3
|
||||
const writer = new BitWriter();
|
||||
writer.writeInt(0, 6); // Wrong type
|
||||
writer.writeInt(1, 6);
|
||||
writer.writeInt(0, 6);
|
||||
writer.writeInt(0, 6);
|
||||
const encoded = bytesToBase64url(writer.toBytes());
|
||||
expect(() => decodeGppHeader(encoded)).toThrow('Invalid GPP header type');
|
||||
});
|
||||
|
||||
it('round-trips: encode → decode → re-encode produces identical output', () => {
|
||||
const header: GppHeader = {
|
||||
version: 1,
|
||||
sectionIds: [7, 8, 11, 14],
|
||||
applicableSections: [7, 8],
|
||||
};
|
||||
const encoded1 = encodeGppHeader(header);
|
||||
const decoded = decodeGppHeader(encoded1);
|
||||
const encoded2 = encodeGppHeader(decoded);
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Section encoding/decoding ────────────────────────────────────────
|
||||
|
||||
describe('US National section (Section 7)', () => {
|
||||
it('encodes and decodes default (all-zero) data', () => {
|
||||
const data = createDefaultSectionData(US_NATIONAL);
|
||||
const encoded = encodeSectionCore(US_NATIONAL, data);
|
||||
const decoded = decodeSectionCore(US_NATIONAL, encoded);
|
||||
|
||||
expect(decoded.Version).toBe(1);
|
||||
expect(decoded.SharingNotice).toBe(0);
|
||||
expect(decoded.SaleOptOut).toBe(0);
|
||||
expect(decoded.SensitiveDataProcessing).toEqual(new Array(12).fill(0));
|
||||
expect(decoded.KnownChildSensitiveDataConsents).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('encodes and decodes populated data', () => {
|
||||
const data: SectionData = {
|
||||
Version: 1,
|
||||
SharingNotice: 1,
|
||||
SaleOptOutNotice: 1,
|
||||
SharingOptOutNotice: 1,
|
||||
TargetedAdvertisingOptOutNotice: 1,
|
||||
SensitiveDataProcessingOptOutNotice: 1,
|
||||
SensitiveDataLimitUseNotice: 1,
|
||||
SaleOptOut: 2,
|
||||
SharingOptOut: 1,
|
||||
TargetedAdvertisingOptOut: 2,
|
||||
SensitiveDataProcessing: [1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0],
|
||||
KnownChildSensitiveDataConsents: [1, 2],
|
||||
PersonalDataConsents: 0,
|
||||
MspaCoveredTransaction: 1,
|
||||
MspaOptOutOptionMode: 1,
|
||||
MspaServiceProviderMode: 0,
|
||||
};
|
||||
const encoded = encodeSectionCore(US_NATIONAL, data);
|
||||
const decoded = decodeSectionCore(US_NATIONAL, encoded);
|
||||
|
||||
expect(decoded.Version).toBe(1);
|
||||
expect(decoded.SharingNotice).toBe(1);
|
||||
expect(decoded.SaleOptOut).toBe(2);
|
||||
expect(decoded.SharingOptOut).toBe(1);
|
||||
expect(decoded.TargetedAdvertisingOptOut).toBe(2);
|
||||
expect(decoded.SensitiveDataProcessing).toEqual([1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0]);
|
||||
expect(decoded.KnownChildSensitiveDataConsents).toEqual([1, 2]);
|
||||
expect(decoded.MspaCoveredTransaction).toBe(1);
|
||||
});
|
||||
|
||||
it('round-trips core data', () => {
|
||||
const data: SectionData = {
|
||||
Version: 1,
|
||||
SharingNotice: 2,
|
||||
SaleOptOutNotice: 1,
|
||||
SharingOptOutNotice: 2,
|
||||
TargetedAdvertisingOptOutNotice: 1,
|
||||
SensitiveDataProcessingOptOutNotice: 2,
|
||||
SensitiveDataLimitUseNotice: 1,
|
||||
SaleOptOut: 1,
|
||||
SharingOptOut: 2,
|
||||
TargetedAdvertisingOptOut: 1,
|
||||
SensitiveDataProcessing: [2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1],
|
||||
KnownChildSensitiveDataConsents: [2, 1],
|
||||
PersonalDataConsents: 2,
|
||||
MspaCoveredTransaction: 2,
|
||||
MspaOptOutOptionMode: 2,
|
||||
MspaServiceProviderMode: 2,
|
||||
};
|
||||
const encoded1 = encodeSectionCore(US_NATIONAL, data);
|
||||
const decoded = decodeSectionCore(US_NATIONAL, encoded1);
|
||||
const encoded2 = encodeSectionCore(US_NATIONAL, decoded);
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('US California section (Section 8)', () => {
|
||||
it('encodes and decodes default data', () => {
|
||||
const data = createDefaultSectionData(US_CALIFORNIA);
|
||||
const encoded = encodeSectionCore(US_CALIFORNIA, data);
|
||||
const decoded = decodeSectionCore(US_CALIFORNIA, encoded);
|
||||
|
||||
expect(decoded.Version).toBe(1);
|
||||
expect(decoded.SaleOptOutNotice).toBe(0);
|
||||
expect(decoded.SensitiveDataProcessing).toEqual(new Array(9).fill(0));
|
||||
expect(decoded.KnownChildSensitiveDataConsents).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('encodes and decodes populated data', () => {
|
||||
const data: SectionData = {
|
||||
Version: 1,
|
||||
SaleOptOutNotice: 1,
|
||||
SharingOptOutNotice: 1,
|
||||
SensitiveDataLimitUseNotice: 2,
|
||||
SaleOptOut: 1,
|
||||
SharingOptOut: 1,
|
||||
SensitiveDataProcessing: [1, 1, 2, 2, 0, 0, 1, 1, 2],
|
||||
KnownChildSensitiveDataConsents: [1, 0],
|
||||
PersonalDataConsents: 1,
|
||||
MspaCoveredTransaction: 1,
|
||||
MspaOptOutOptionMode: 0,
|
||||
MspaServiceProviderMode: 2,
|
||||
};
|
||||
const encoded = encodeSectionCore(US_CALIFORNIA, data);
|
||||
const decoded = decodeSectionCore(US_CALIFORNIA, encoded);
|
||||
|
||||
expect(decoded.SaleOptOut).toBe(1);
|
||||
expect(decoded.SharingOptOut).toBe(1);
|
||||
expect(decoded.SensitiveDataProcessing).toEqual([1, 1, 2, 2, 0, 0, 1, 1, 2]);
|
||||
expect(decoded.MspaServiceProviderMode).toBe(2);
|
||||
});
|
||||
|
||||
it('round-trips core data', () => {
|
||||
const data: SectionData = {
|
||||
Version: 1,
|
||||
SaleOptOutNotice: 2,
|
||||
SharingOptOutNotice: 1,
|
||||
SensitiveDataLimitUseNotice: 1,
|
||||
SaleOptOut: 2,
|
||||
SharingOptOut: 2,
|
||||
SensitiveDataProcessing: [2, 2, 1, 1, 0, 0, 2, 2, 1],
|
||||
KnownChildSensitiveDataConsents: [2, 2],
|
||||
PersonalDataConsents: 2,
|
||||
MspaCoveredTransaction: 1,
|
||||
MspaOptOutOptionMode: 1,
|
||||
MspaServiceProviderMode: 1,
|
||||
};
|
||||
const e1 = encodeSectionCore(US_CALIFORNIA, data);
|
||||
const d = decodeSectionCore(US_CALIFORNIA, e1);
|
||||
const e2 = encodeSectionCore(US_CALIFORNIA, d);
|
||||
expect(e2).toBe(e1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('US Virginia section (Section 9)', () => {
|
||||
it('encodes and decodes default data', () => {
|
||||
const data = createDefaultSectionData(US_VIRGINIA);
|
||||
const encoded = encodeSectionCore(US_VIRGINIA, data);
|
||||
const decoded = decodeSectionCore(US_VIRGINIA, encoded);
|
||||
|
||||
expect(decoded.Version).toBe(1);
|
||||
expect(decoded.SensitiveDataProcessing).toEqual(new Array(8).fill(0));
|
||||
expect(decoded.KnownChildSensitiveDataConsents).toBe(0);
|
||||
});
|
||||
|
||||
it('round-trips core data', () => {
|
||||
const data: SectionData = {
|
||||
Version: 1,
|
||||
SharingNotice: 1,
|
||||
SaleOptOutNotice: 2,
|
||||
TargetedAdvertisingOptOutNotice: 1,
|
||||
SaleOptOut: 2,
|
||||
TargetedAdvertisingOptOut: 1,
|
||||
SensitiveDataProcessing: [1, 2, 1, 2, 1, 2, 1, 2],
|
||||
KnownChildSensitiveDataConsents: 1,
|
||||
MspaCoveredTransaction: 2,
|
||||
MspaOptOutOptionMode: 1,
|
||||
MspaServiceProviderMode: 2,
|
||||
};
|
||||
const e1 = encodeSectionCore(US_VIRGINIA, data);
|
||||
const d = decodeSectionCore(US_VIRGINIA, e1);
|
||||
const e2 = encodeSectionCore(US_VIRGINIA, d);
|
||||
expect(e2).toBe(e1);
|
||||
});
|
||||
|
||||
it('does not support GPC sub-section', () => {
|
||||
expect(US_VIRGINIA.hasGpcSubsection).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('US Colorado section (Section 10)', () => {
|
||||
it('encodes and decodes default data', () => {
|
||||
const data = createDefaultSectionData(US_COLORADO);
|
||||
const encoded = encodeSectionCore(US_COLORADO, data);
|
||||
const decoded = decodeSectionCore(US_COLORADO, encoded);
|
||||
|
||||
expect(decoded.Version).toBe(1);
|
||||
expect(decoded.SensitiveDataProcessing).toEqual(new Array(7).fill(0));
|
||||
});
|
||||
|
||||
it('round-trips core data', () => {
|
||||
const data: SectionData = {
|
||||
Version: 1,
|
||||
SharingNotice: 2,
|
||||
SaleOptOutNotice: 1,
|
||||
TargetedAdvertisingOptOutNotice: 2,
|
||||
SaleOptOut: 1,
|
||||
TargetedAdvertisingOptOut: 2,
|
||||
SensitiveDataProcessing: [2, 1, 0, 2, 1, 0, 2],
|
||||
KnownChildSensitiveDataConsents: 1,
|
||||
MspaCoveredTransaction: 1,
|
||||
MspaOptOutOptionMode: 2,
|
||||
MspaServiceProviderMode: 0,
|
||||
};
|
||||
const e1 = encodeSectionCore(US_COLORADO, data);
|
||||
const d = decodeSectionCore(US_COLORADO, e1);
|
||||
const e2 = encodeSectionCore(US_COLORADO, d);
|
||||
expect(e2).toBe(e1);
|
||||
});
|
||||
|
||||
it('supports GPC sub-section', () => {
|
||||
expect(US_COLORADO.hasGpcSubsection).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('US Connecticut section (Section 11)', () => {
|
||||
it('encodes and decodes default data', () => {
|
||||
const data = createDefaultSectionData(US_CONNECTICUT);
|
||||
const encoded = encodeSectionCore(US_CONNECTICUT, data);
|
||||
const decoded = decodeSectionCore(US_CONNECTICUT, encoded);
|
||||
|
||||
expect(decoded.Version).toBe(1);
|
||||
expect(decoded.SensitiveDataProcessing).toEqual(new Array(8).fill(0));
|
||||
expect(decoded.KnownChildSensitiveDataConsents).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it('round-trips core data', () => {
|
||||
const data: SectionData = {
|
||||
Version: 1,
|
||||
SharingNotice: 1,
|
||||
SaleOptOutNotice: 1,
|
||||
TargetedAdvertisingOptOutNotice: 1,
|
||||
SaleOptOut: 1,
|
||||
TargetedAdvertisingOptOut: 1,
|
||||
SensitiveDataProcessing: [1, 1, 1, 1, 1, 1, 1, 1],
|
||||
KnownChildSensitiveDataConsents: [1, 2, 1],
|
||||
MspaCoveredTransaction: 2,
|
||||
MspaOptOutOptionMode: 0,
|
||||
MspaServiceProviderMode: 0,
|
||||
};
|
||||
const e1 = encodeSectionCore(US_CONNECTICUT, data);
|
||||
const d = decodeSectionCore(US_CONNECTICUT, e1);
|
||||
const e2 = encodeSectionCore(US_CONNECTICUT, d);
|
||||
expect(e2).toBe(e1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('US Florida section (Section 14)', () => {
|
||||
it('encodes and decodes default data', () => {
|
||||
const data = createDefaultSectionData(US_FLORIDA);
|
||||
const encoded = encodeSectionCore(US_FLORIDA, data);
|
||||
const decoded = decodeSectionCore(US_FLORIDA, encoded);
|
||||
|
||||
expect(decoded.Version).toBe(1);
|
||||
expect(decoded.SensitiveDataProcessing).toEqual(new Array(8).fill(0));
|
||||
expect(decoded.KnownChildSensitiveDataConsents).toEqual([0, 0, 0]);
|
||||
expect(decoded.PersonalDataConsents).toBe(0);
|
||||
});
|
||||
|
||||
it('round-trips core data', () => {
|
||||
const data: SectionData = {
|
||||
Version: 1,
|
||||
SharingNotice: 2,
|
||||
SaleOptOutNotice: 2,
|
||||
TargetedAdvertisingOptOutNotice: 2,
|
||||
SaleOptOut: 2,
|
||||
TargetedAdvertisingOptOut: 2,
|
||||
SensitiveDataProcessing: [2, 2, 2, 2, 2, 2, 2, 2],
|
||||
KnownChildSensitiveDataConsents: [2, 2, 2],
|
||||
PersonalDataConsents: 2,
|
||||
MspaCoveredTransaction: 2,
|
||||
MspaOptOutOptionMode: 2,
|
||||
MspaServiceProviderMode: 2,
|
||||
};
|
||||
const e1 = encodeSectionCore(US_FLORIDA, data);
|
||||
const d = decodeSectionCore(US_FLORIDA, e1);
|
||||
const e2 = encodeSectionCore(US_FLORIDA, d);
|
||||
expect(e2).toBe(e1);
|
||||
});
|
||||
|
||||
it('supports GPC sub-section', () => {
|
||||
expect(US_FLORIDA.hasGpcSubsection).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GPC sub-section ──────────────────────────────────────────────────
|
||||
|
||||
describe('GPC sub-section', () => {
|
||||
it('encodes and decodes gpc=true', () => {
|
||||
const encoded = encodeGpcSubsection({ gpc: true });
|
||||
const decoded = decodeGpcSubsection(encoded);
|
||||
expect(decoded.gpc).toBe(true);
|
||||
});
|
||||
|
||||
it('encodes and decodes gpc=false', () => {
|
||||
const encoded = encodeGpcSubsection({ gpc: false });
|
||||
const decoded = decodeGpcSubsection(encoded);
|
||||
expect(decoded.gpc).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips: encode → decode → re-encode is identical', () => {
|
||||
const gpc = { gpc: true };
|
||||
const e1 = encodeGpcSubsection(gpc);
|
||||
const d = decodeGpcSubsection(e1);
|
||||
const e2 = encodeGpcSubsection(d);
|
||||
expect(e2).toBe(e1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Full section with GPC sub-section ────────────────────────────────
|
||||
|
||||
describe('encodeSection / decodeSection (core + GPC)', () => {
|
||||
it('encodes section without GPC when not provided', () => {
|
||||
const data = createDefaultSectionData(US_NATIONAL);
|
||||
const encoded = encodeSection(US_NATIONAL, data);
|
||||
expect(encoded).not.toContain('.');
|
||||
});
|
||||
|
||||
it('encodes section with GPC sub-section', () => {
|
||||
const data = createDefaultSectionData(US_NATIONAL);
|
||||
const encoded = encodeSection(US_NATIONAL, data, { gpc: true });
|
||||
expect(encoded).toContain('.');
|
||||
});
|
||||
|
||||
it('decodes section with GPC sub-section', () => {
|
||||
const data: SectionData = {
|
||||
...createDefaultSectionData(US_NATIONAL),
|
||||
SaleOptOut: 1,
|
||||
SharingOptOut: 1,
|
||||
};
|
||||
const encoded = encodeSection(US_NATIONAL, data, { gpc: true });
|
||||
const { data: decoded, gpcSubsection } = decodeSection(US_NATIONAL, encoded);
|
||||
|
||||
expect(decoded.SaleOptOut).toBe(1);
|
||||
expect(decoded.SharingOptOut).toBe(1);
|
||||
expect(gpcSubsection).toBeDefined();
|
||||
expect(gpcSubsection!.gpc).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores GPC sub-section for sections that do not support it', () => {
|
||||
const data = createDefaultSectionData(US_VIRGINIA);
|
||||
const encoded = encodeSection(US_VIRGINIA, data, { gpc: true });
|
||||
// Virginia doesn't support GPC — should not append sub-section
|
||||
expect(encoded).not.toContain('.');
|
||||
|
||||
const { gpcSubsection } = decodeSection(US_VIRGINIA, encoded);
|
||||
expect(gpcSubsection).toBeUndefined();
|
||||
});
|
||||
|
||||
it('round-trips section with GPC', () => {
|
||||
const data: SectionData = {
|
||||
...createDefaultSectionData(US_CALIFORNIA),
|
||||
SaleOptOut: 2,
|
||||
SharingOptOut: 1,
|
||||
};
|
||||
const gpc = { gpc: false };
|
||||
const e1 = encodeSection(US_CALIFORNIA, data, gpc);
|
||||
const { data: d, gpcSubsection } = decodeSection(US_CALIFORNIA, e1);
|
||||
const e2 = encodeSection(US_CALIFORNIA, d, gpcSubsection);
|
||||
expect(e2).toBe(e1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Full GPP string ──────────────────────────────────────────────────
|
||||
|
||||
describe('full GPP string encoding/decoding', () => {
|
||||
it('encodes and decodes a single US National section', () => {
|
||||
const gpp: GppString = {
|
||||
header: {
|
||||
version: 1,
|
||||
sectionIds: [7],
|
||||
applicableSections: [7],
|
||||
},
|
||||
sections: new Map([
|
||||
[7, { data: createDefaultSectionData(US_NATIONAL), gpcSubsection: { gpc: false } }],
|
||||
]),
|
||||
};
|
||||
|
||||
const encoded = encodeGppString(gpp);
|
||||
expect(encoded).toContain('~');
|
||||
|
||||
const decoded = decodeGppString(encoded);
|
||||
expect(decoded.header.sectionIds).toEqual([7]);
|
||||
expect(decoded.sections.has(7)).toBe(true);
|
||||
expect(decoded.sections.get(7)!.data.Version).toBe(1);
|
||||
expect(decoded.sections.get(7)!.gpcSubsection?.gpc).toBe(false);
|
||||
});
|
||||
|
||||
it('encodes and decodes multiple sections', () => {
|
||||
const gpp: GppString = {
|
||||
header: {
|
||||
version: 1,
|
||||
sectionIds: [7, 8, 10],
|
||||
applicableSections: [8],
|
||||
},
|
||||
sections: new Map([
|
||||
[7, {
|
||||
data: {
|
||||
...createDefaultSectionData(US_NATIONAL),
|
||||
SaleOptOut: 1,
|
||||
SharingOptOut: 1,
|
||||
},
|
||||
gpcSubsection: { gpc: true },
|
||||
}],
|
||||
[8, {
|
||||
data: {
|
||||
...createDefaultSectionData(US_CALIFORNIA),
|
||||
SaleOptOut: 1,
|
||||
},
|
||||
gpcSubsection: { gpc: true },
|
||||
}],
|
||||
[10, {
|
||||
data: {
|
||||
...createDefaultSectionData(US_COLORADO),
|
||||
TargetedAdvertisingOptOut: 2,
|
||||
},
|
||||
gpcSubsection: { gpc: false },
|
||||
}],
|
||||
]),
|
||||
};
|
||||
|
||||
const encoded = encodeGppString(gpp);
|
||||
const parts = encoded.split('~');
|
||||
expect(parts.length).toBe(4); // header + 3 sections
|
||||
|
||||
const decoded = decodeGppString(encoded);
|
||||
expect(decoded.header.sectionIds).toEqual([7, 8, 10]);
|
||||
expect(decoded.header.applicableSections).toEqual([8]);
|
||||
expect(decoded.sections.get(7)!.data.SaleOptOut).toBe(1);
|
||||
expect(decoded.sections.get(7)!.gpcSubsection?.gpc).toBe(true);
|
||||
expect(decoded.sections.get(8)!.data.SaleOptOut).toBe(1);
|
||||
expect(decoded.sections.get(10)!.data.TargetedAdvertisingOptOut).toBe(2);
|
||||
});
|
||||
|
||||
it('round-trips a complex GPP string', () => {
|
||||
const gpp: GppString = {
|
||||
header: {
|
||||
version: 1,
|
||||
sectionIds: [7, 8, 9, 10, 11, 14],
|
||||
applicableSections: [7, 8],
|
||||
},
|
||||
sections: new Map([
|
||||
[7, {
|
||||
data: {
|
||||
...createDefaultSectionData(US_NATIONAL),
|
||||
SharingNotice: 1,
|
||||
SaleOptOutNotice: 1,
|
||||
SaleOptOut: 1,
|
||||
SharingOptOut: 1,
|
||||
TargetedAdvertisingOptOut: 1,
|
||||
SensitiveDataProcessing: [1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2],
|
||||
KnownChildSensitiveDataConsents: [0, 0],
|
||||
MspaCoveredTransaction: 1,
|
||||
},
|
||||
gpcSubsection: { gpc: true },
|
||||
}],
|
||||
[8, {
|
||||
data: {
|
||||
...createDefaultSectionData(US_CALIFORNIA),
|
||||
SaleOptOut: 1,
|
||||
SharingOptOut: 1,
|
||||
SensitiveDataProcessing: [2, 2, 2, 2, 2, 2, 2, 2, 2],
|
||||
},
|
||||
gpcSubsection: { gpc: true },
|
||||
}],
|
||||
[9, {
|
||||
data: {
|
||||
...createDefaultSectionData(US_VIRGINIA),
|
||||
SaleOptOut: 2,
|
||||
},
|
||||
}],
|
||||
[10, {
|
||||
data: createDefaultSectionData(US_COLORADO),
|
||||
gpcSubsection: { gpc: false },
|
||||
}],
|
||||
[11, {
|
||||
data: createDefaultSectionData(US_CONNECTICUT),
|
||||
gpcSubsection: { gpc: false },
|
||||
}],
|
||||
[14, {
|
||||
data: createDefaultSectionData(US_FLORIDA),
|
||||
gpcSubsection: { gpc: true },
|
||||
}],
|
||||
]),
|
||||
};
|
||||
|
||||
const encoded1 = encodeGppString(gpp);
|
||||
const decoded = decodeGppString(encoded1);
|
||||
const encoded2 = encodeGppString(decoded);
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
|
||||
it('throws when section data is missing', () => {
|
||||
const gpp: GppString = {
|
||||
header: {
|
||||
version: 1,
|
||||
sectionIds: [7, 8],
|
||||
applicableSections: [],
|
||||
},
|
||||
sections: new Map([
|
||||
[7, { data: createDefaultSectionData(US_NATIONAL) }],
|
||||
// Section 8 intentionally missing
|
||||
]),
|
||||
};
|
||||
|
||||
expect(() => encodeGppString(gpp)).toThrow('No data or definition for GPP section 8');
|
||||
});
|
||||
|
||||
it('throws when section payload is missing during decode', () => {
|
||||
// Manually craft a string with a header claiming 2 sections but only 1 payload
|
||||
const header: GppHeader = {
|
||||
version: 1,
|
||||
sectionIds: [7, 8],
|
||||
applicableSections: [],
|
||||
};
|
||||
const headerStr = encodeGppHeader(header);
|
||||
const sectionStr = encodeSectionCore(US_NATIONAL, createDefaultSectionData(US_NATIONAL));
|
||||
const gppStr = `${headerStr}~${sectionStr}`;
|
||||
// Missing section 8 payload
|
||||
|
||||
expect(() => decodeGppString(gppStr)).toThrow('Missing payload for GPP section 8');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Convenience helpers ──────────────────────────────────────────────
|
||||
|
||||
describe('createDefaultSectionData', () => {
|
||||
it('creates default data with correct Version for each section', () => {
|
||||
for (const def of SECTION_REGISTRY.values()) {
|
||||
const data = createDefaultSectionData(def);
|
||||
expect(data.Version).toBe(def.version);
|
||||
}
|
||||
});
|
||||
|
||||
it('creates correct array sizes for US National', () => {
|
||||
const data = createDefaultSectionData(US_NATIONAL);
|
||||
expect((data.SensitiveDataProcessing as number[]).length).toBe(12);
|
||||
expect((data.KnownChildSensitiveDataConsents as number[]).length).toBe(2);
|
||||
});
|
||||
|
||||
it('creates correct array sizes for US California', () => {
|
||||
const data = createDefaultSectionData(US_CALIFORNIA);
|
||||
expect((data.SensitiveDataProcessing as number[]).length).toBe(9);
|
||||
expect((data.KnownChildSensitiveDataConsents as number[]).length).toBe(2);
|
||||
});
|
||||
|
||||
it('creates correct array sizes for US Connecticut', () => {
|
||||
const data = createDefaultSectionData(US_CONNECTICUT);
|
||||
expect((data.SensitiveDataProcessing as number[]).length).toBe(8);
|
||||
expect((data.KnownChildSensitiveDataConsents as number[]).length).toBe(3);
|
||||
});
|
||||
|
||||
it('uses scalar for single-count fields', () => {
|
||||
const data = createDefaultSectionData(US_VIRGINIA);
|
||||
expect(typeof data.KnownChildSensitiveDataConsents).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSectionByPrefix', () => {
|
||||
it('finds US National by prefix', () => {
|
||||
expect(getSectionByPrefix('usnat')).toBe(US_NATIONAL);
|
||||
});
|
||||
|
||||
it('finds US California by prefix', () => {
|
||||
expect(getSectionByPrefix('usca')).toBe(US_CALIFORNIA);
|
||||
});
|
||||
|
||||
it('finds US Virginia by prefix', () => {
|
||||
expect(getSectionByPrefix('usva')).toBe(US_VIRGINIA);
|
||||
});
|
||||
|
||||
it('finds US Colorado by prefix', () => {
|
||||
expect(getSectionByPrefix('usco')).toBe(US_COLORADO);
|
||||
});
|
||||
|
||||
it('finds US Connecticut by prefix', () => {
|
||||
expect(getSectionByPrefix('usct')).toBe(US_CONNECTICUT);
|
||||
});
|
||||
|
||||
it('finds US Florida by prefix', () => {
|
||||
expect(getSectionByPrefix('usfl')).toBe(US_FLORIDA);
|
||||
});
|
||||
|
||||
it('returns undefined for unknown prefix', () => {
|
||||
expect(getSectionByPrefix('unknown')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerSection', () => {
|
||||
it('registers a custom section definition', () => {
|
||||
const customDef: SectionDef = {
|
||||
id: 99,
|
||||
apiPrefix: 'custom',
|
||||
version: 1,
|
||||
coreFields: [
|
||||
{ name: 'Version', bits: 6, count: 1 },
|
||||
{ name: 'OptOut', bits: 2, count: 1 },
|
||||
],
|
||||
hasGpcSubsection: false,
|
||||
};
|
||||
|
||||
registerSection(customDef);
|
||||
expect(SECTION_REGISTRY.get(99)).toBe(customDef);
|
||||
expect(getSectionByPrefix('custom')).toBe(customDef);
|
||||
|
||||
// Encode and decode using the custom section
|
||||
const data: SectionData = { Version: 1, OptOut: 2 };
|
||||
const encoded = encodeSectionCore(customDef, data);
|
||||
const decoded = decodeSectionCore(customDef, encoded);
|
||||
expect(decoded.Version).toBe(1);
|
||||
expect(decoded.OptOut).toBe(2);
|
||||
|
||||
// Clean up
|
||||
SECTION_REGISTRY.delete(99);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Section registry completeness ────────────────────────────────────
|
||||
|
||||
describe('section registry', () => {
|
||||
it('contains all required sections', () => {
|
||||
expect(SECTION_REGISTRY.has(7)).toBe(true); // US National
|
||||
expect(SECTION_REGISTRY.has(8)).toBe(true); // US California
|
||||
expect(SECTION_REGISTRY.has(9)).toBe(true); // US Virginia
|
||||
expect(SECTION_REGISTRY.has(10)).toBe(true); // US Colorado
|
||||
expect(SECTION_REGISTRY.has(11)).toBe(true); // US Connecticut
|
||||
expect(SECTION_REGISTRY.has(14)).toBe(true); // US Florida
|
||||
});
|
||||
|
||||
it('each section has a unique apiPrefix', () => {
|
||||
const prefixes = new Set<string>();
|
||||
for (const def of SECTION_REGISTRY.values()) {
|
||||
expect(prefixes.has(def.apiPrefix)).toBe(false);
|
||||
prefixes.add(def.apiPrefix);
|
||||
}
|
||||
});
|
||||
|
||||
it('each section has a unique id', () => {
|
||||
const ids = new Set<number>();
|
||||
for (const def of SECTION_REGISTRY.values()) {
|
||||
expect(ids.has(def.id)).toBe(false);
|
||||
ids.add(def.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── GppFieldValue constants ──────────────────────────────────────────
|
||||
|
||||
describe('GppFieldValue constants', () => {
|
||||
it('has correct values', () => {
|
||||
expect(GppFieldValue.NOT_APPLICABLE).toBe(0);
|
||||
expect(GppFieldValue.YES).toBe(1);
|
||||
expect(GppFieldValue.NO).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edge cases ───────────────────────────────────────────────────────
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles all-maximum field values for US National', () => {
|
||||
const data: SectionData = {
|
||||
Version: 63, // max for 6 bits
|
||||
SharingNotice: 3, // max for 2 bits
|
||||
SaleOptOutNotice: 3,
|
||||
SharingOptOutNotice: 3,
|
||||
TargetedAdvertisingOptOutNotice: 3,
|
||||
SensitiveDataProcessingOptOutNotice: 3,
|
||||
SensitiveDataLimitUseNotice: 3,
|
||||
SaleOptOut: 3,
|
||||
SharingOptOut: 3,
|
||||
TargetedAdvertisingOptOut: 3,
|
||||
SensitiveDataProcessing: new Array(12).fill(3),
|
||||
KnownChildSensitiveDataConsents: [3, 3],
|
||||
PersonalDataConsents: 3,
|
||||
MspaCoveredTransaction: 3,
|
||||
MspaOptOutOptionMode: 3,
|
||||
MspaServiceProviderMode: 3,
|
||||
};
|
||||
const encoded = encodeSectionCore(US_NATIONAL, data);
|
||||
const decoded = decodeSectionCore(US_NATIONAL, encoded);
|
||||
expect(decoded.Version).toBe(63);
|
||||
expect(decoded.SharingNotice).toBe(3);
|
||||
expect(decoded.SensitiveDataProcessing).toEqual(new Array(12).fill(3));
|
||||
});
|
||||
|
||||
it('handles a GPP string with all 6 registered sections', () => {
|
||||
const allSectionIds = [7, 8, 9, 10, 11, 14];
|
||||
const sections = new Map<number, { data: SectionData; gpcSubsection?: { gpc: boolean } }>();
|
||||
|
||||
for (const id of allSectionIds) {
|
||||
const def = SECTION_REGISTRY.get(id)!;
|
||||
sections.set(id, {
|
||||
data: createDefaultSectionData(def),
|
||||
gpcSubsection: def.hasGpcSubsection ? { gpc: true } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const gpp: GppString = {
|
||||
header: {
|
||||
version: 1,
|
||||
sectionIds: allSectionIds,
|
||||
applicableSections: allSectionIds,
|
||||
},
|
||||
sections,
|
||||
};
|
||||
|
||||
const encoded = encodeGppString(gpp);
|
||||
const decoded = decodeGppString(encoded);
|
||||
|
||||
expect(decoded.header.sectionIds).toEqual(allSectionIds);
|
||||
expect(decoded.sections.size).toBe(6);
|
||||
|
||||
for (const id of allSectionIds) {
|
||||
expect(decoded.sections.has(id)).toBe(true);
|
||||
const def = SECTION_REGISTRY.get(id)!;
|
||||
if (def.hasGpcSubsection) {
|
||||
expect(decoded.sections.get(id)!.gpcSubsection?.gpc).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
190
apps/banner/src/__tests__/i18n.test.ts
Normal file
190
apps/banner/src/__tests__/i18n.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_TRANSLATIONS,
|
||||
detectLocale,
|
||||
fetchTranslations,
|
||||
interpolate,
|
||||
loadTranslations,
|
||||
normaliseLocale,
|
||||
} from '../i18n';
|
||||
|
||||
describe('i18n', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('DEFAULT_TRANSLATIONS', () => {
|
||||
it('should have all required keys', () => {
|
||||
expect(DEFAULT_TRANSLATIONS.title).toBe('We use cookies');
|
||||
expect(DEFAULT_TRANSLATIONS.acceptAll).toBe('Accept all');
|
||||
expect(DEFAULT_TRANSLATIONS.rejectAll).toBe('Reject all');
|
||||
expect(DEFAULT_TRANSLATIONS.managePreferences).toBe('Manage preferences');
|
||||
expect(DEFAULT_TRANSLATIONS.savePreferences).toBe('Save preferences');
|
||||
expect(DEFAULT_TRANSLATIONS.privacyPolicyLink).toBe('Privacy Policy');
|
||||
expect(DEFAULT_TRANSLATIONS.closeLabel).toBe('Close');
|
||||
});
|
||||
|
||||
it('should have all category translations', () => {
|
||||
expect(DEFAULT_TRANSLATIONS.categoryNecessary).toBe('Necessary');
|
||||
expect(DEFAULT_TRANSLATIONS.categoryFunctional).toBe('Functional');
|
||||
expect(DEFAULT_TRANSLATIONS.categoryAnalytics).toBe('Analytics');
|
||||
expect(DEFAULT_TRANSLATIONS.categoryMarketing).toBe('Marketing');
|
||||
expect(DEFAULT_TRANSLATIONS.categoryPersonalisation).toBe('Personalisation');
|
||||
});
|
||||
|
||||
it('should have category descriptions', () => {
|
||||
expect(DEFAULT_TRANSLATIONS.categoryNecessaryDesc).toBeTruthy();
|
||||
expect(DEFAULT_TRANSLATIONS.categoryFunctionalDesc).toBeTruthy();
|
||||
expect(DEFAULT_TRANSLATIONS.categoryAnalyticsDesc).toBeTruthy();
|
||||
expect(DEFAULT_TRANSLATIONS.categoryMarketingDesc).toBeTruthy();
|
||||
expect(DEFAULT_TRANSLATIONS.categoryPersonalisationDesc).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have cookie count template with placeholder', () => {
|
||||
expect(DEFAULT_TRANSLATIONS.cookieCount).toContain('{{count}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normaliseLocale', () => {
|
||||
it('should extract language code from locale', () => {
|
||||
expect(normaliseLocale('en-GB')).toBe('en');
|
||||
expect(normaliseLocale('fr-FR')).toBe('fr');
|
||||
expect(normaliseLocale('de-DE')).toBe('de');
|
||||
});
|
||||
|
||||
it('should handle simple language codes', () => {
|
||||
expect(normaliseLocale('en')).toBe('en');
|
||||
expect(normaliseLocale('fr')).toBe('fr');
|
||||
});
|
||||
|
||||
it('should lowercase the result', () => {
|
||||
expect(normaliseLocale('EN-GB')).toBe('en');
|
||||
expect(normaliseLocale('FR')).toBe('fr');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectLocale', () => {
|
||||
it('should use data-locale attribute when present', () => {
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('data-site-id', 'site-1');
|
||||
script.setAttribute('data-locale', 'fr-FR');
|
||||
document.head.appendChild(script);
|
||||
|
||||
expect(detectLocale()).toBe('fr');
|
||||
|
||||
script.remove();
|
||||
});
|
||||
|
||||
it('should fall back to navigator.language', () => {
|
||||
vi.spyOn(navigator, 'language', 'get').mockReturnValue('de-DE');
|
||||
expect(detectLocale()).toBe('de');
|
||||
});
|
||||
|
||||
it('should fall back to document lang', () => {
|
||||
vi.spyOn(navigator, 'language', 'get').mockReturnValue('');
|
||||
document.documentElement.lang = 'es';
|
||||
|
||||
expect(detectLocale()).toBe('es');
|
||||
|
||||
document.documentElement.lang = '';
|
||||
});
|
||||
|
||||
it('should default to en', () => {
|
||||
vi.spyOn(navigator, 'language', 'get').mockReturnValue('');
|
||||
document.documentElement.lang = '';
|
||||
|
||||
expect(detectLocale()).toBe('en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTranslations', () => {
|
||||
it('should fetch and parse translations', async () => {
|
||||
const mockTranslations = { title: 'Nous utilisons des cookies' };
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTranslations),
|
||||
}));
|
||||
|
||||
const result = await fetchTranslations('https://cdn.example.com', 'fr');
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('https://cdn.example.com/translations-fr.json');
|
||||
expect(result).toEqual(mockTranslations);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('should return null on 404', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
|
||||
|
||||
const result = await fetchTranslations('https://cdn.example.com', 'zz');
|
||||
expect(result).toBeNull();
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('should return null on network error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||||
|
||||
const result = await fetchTranslations('https://cdn.example.com', 'fr');
|
||||
expect(result).toBeNull();
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadTranslations', () => {
|
||||
it('should return defaults for English locale', async () => {
|
||||
const t = await loadTranslations('https://cdn.example.com', 'en');
|
||||
expect(t.title).toBe(DEFAULT_TRANSLATIONS.title);
|
||||
expect(t.acceptAll).toBe(DEFAULT_TRANSLATIONS.acceptAll);
|
||||
});
|
||||
|
||||
it('should merge remote translations over defaults', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ title: 'Wir verwenden Cookies', acceptAll: 'Alle akzeptieren' }),
|
||||
}));
|
||||
|
||||
const t = await loadTranslations('https://cdn.example.com', 'de');
|
||||
|
||||
expect(t.title).toBe('Wir verwenden Cookies');
|
||||
expect(t.acceptAll).toBe('Alle akzeptieren');
|
||||
// Missing keys should fall back to English
|
||||
expect(t.rejectAll).toBe(DEFAULT_TRANSLATIONS.rejectAll);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('should fall back to defaults when fetch fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('fail')));
|
||||
|
||||
const t = await loadTranslations('https://cdn.example.com', 'fr');
|
||||
expect(t.title).toBe(DEFAULT_TRANSLATIONS.title);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolate', () => {
|
||||
it('should replace placeholders with values', () => {
|
||||
expect(interpolate('{{count}} cookies', { count: '12' })).toBe('12 cookies');
|
||||
});
|
||||
|
||||
it('should handle multiple placeholders', () => {
|
||||
expect(interpolate('{{a}} and {{b}}', { a: 'X', b: 'Y' })).toBe('X and Y');
|
||||
});
|
||||
|
||||
it('should replace missing keys with empty string', () => {
|
||||
expect(interpolate('Hello {{name}}', {})).toBe('Hello ');
|
||||
});
|
||||
|
||||
it('should handle templates without placeholders', () => {
|
||||
expect(interpolate('No placeholders', { key: 'value' })).toBe('No placeholders');
|
||||
});
|
||||
|
||||
it('should handle empty template', () => {
|
||||
expect(interpolate('', { key: 'value' })).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
157
apps/banner/src/__tests__/loader.test.ts
Normal file
157
apps/banner/src/__tests__/loader.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock the imports before loading the loader module
|
||||
vi.mock('../blocker', () => ({
|
||||
installBlocker: vi.fn(),
|
||||
updateAcceptedCategories: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../consent', () => ({
|
||||
hasConsent: vi.fn(),
|
||||
readConsent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../gcm', () => ({
|
||||
buildDeniedDefaults: vi.fn(() => ({ analytics_storage: 'denied' })),
|
||||
buildGcmStateFromCategories: vi.fn(() => ({ analytics_storage: 'granted' })),
|
||||
setGcmDefaults: vi.fn(),
|
||||
updateGcm: vi.fn(),
|
||||
}));
|
||||
|
||||
import { installBlocker, updateAcceptedCategories } from '../blocker';
|
||||
import { readConsent } from '../consent';
|
||||
import { buildDeniedDefaults, buildGcmStateFromCategories, setGcmDefaults, updateGcm } from '../gcm';
|
||||
|
||||
describe('loader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.dataLayer = [];
|
||||
// @ts-expect-error — reset for test isolation
|
||||
window.__consentos = undefined;
|
||||
// Reset document.currentScript
|
||||
Object.defineProperty(document, 'currentScript', {
|
||||
value: null,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
// We can't import the loader directly (it self-executes as an IIFE),
|
||||
// so we test the individual functions it composes instead.
|
||||
|
||||
describe('installBlocker integration', () => {
|
||||
it('installBlocker should be callable', () => {
|
||||
installBlocker();
|
||||
expect(installBlocker).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GCM defaults', () => {
|
||||
it('should build denied defaults and set them', () => {
|
||||
const defaults = buildDeniedDefaults();
|
||||
setGcmDefaults(defaults);
|
||||
expect(buildDeniedDefaults).toHaveBeenCalled();
|
||||
expect(setGcmDefaults).toHaveBeenCalledWith(defaults);
|
||||
});
|
||||
});
|
||||
|
||||
describe('existing consent flow', () => {
|
||||
it('should update blocker and GCM when consent exists', () => {
|
||||
const consent = {
|
||||
accepted: ['necessary', 'analytics'],
|
||||
rejected: ['marketing'],
|
||||
visitorId: 'v123',
|
||||
consentedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
vi.mocked(readConsent).mockReturnValue(consent as any);
|
||||
|
||||
const existingConsent = readConsent();
|
||||
expect(existingConsent).toBeDefined();
|
||||
|
||||
if (existingConsent) {
|
||||
updateAcceptedCategories(existingConsent.accepted as any);
|
||||
const gcmState = buildGcmStateFromCategories(existingConsent.accepted);
|
||||
updateGcm(gcmState);
|
||||
|
||||
expect(updateAcceptedCategories).toHaveBeenCalledWith(existingConsent.accepted);
|
||||
expect(buildGcmStateFromCategories).toHaveBeenCalledWith(existingConsent.accepted);
|
||||
expect(updateGcm).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not update blocker when no consent exists', () => {
|
||||
vi.mocked(readConsent).mockReturnValue(null);
|
||||
|
||||
const existingConsent = readConsent();
|
||||
expect(existingConsent).toBeNull();
|
||||
|
||||
// In this case, the loader would load the banner bundle
|
||||
// updateAcceptedCategories should NOT have been called
|
||||
expect(updateAcceptedCategories).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('consent-change event', () => {
|
||||
it('should dispatch consentos:consent-change custom event', () => {
|
||||
const accepted = ['necessary', 'analytics'];
|
||||
let receivedDetail: unknown = null;
|
||||
|
||||
document.addEventListener('consentos:consent-change', ((e: CustomEvent) => {
|
||||
receivedDetail = e.detail;
|
||||
}) as EventListener);
|
||||
|
||||
const event = new CustomEvent('consentos:consent-change', {
|
||||
detail: { accepted },
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(receivedDetail).toEqual({ accepted });
|
||||
});
|
||||
|
||||
it('should push to dataLayer if it exists', () => {
|
||||
window.dataLayer = [];
|
||||
const accepted = ['necessary', 'functional'];
|
||||
|
||||
window.dataLayer.push({
|
||||
event: 'consentos_consent_change',
|
||||
cmp_accepted_categories: accepted,
|
||||
});
|
||||
|
||||
expect(window.dataLayer).toHaveLength(1);
|
||||
expect(window.dataLayer[0]).toEqual({
|
||||
event: 'consentos_consent_change',
|
||||
cmp_accepted_categories: accepted,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadBannerBundle', () => {
|
||||
it('should create a script element for the banner bundle', () => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.example.com/consent-bundle.js';
|
||||
script.async = true;
|
||||
document.head.appendChild(script);
|
||||
|
||||
const found = document.querySelector('script[src*="consent-bundle"]');
|
||||
expect(found).not.toBeNull();
|
||||
|
||||
// Clean up
|
||||
found?.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('__cmp global', () => {
|
||||
it('should expose the CMP context on window', () => {
|
||||
window.__consentos = {
|
||||
siteId: 'test-site-id',
|
||||
apiBase: 'https://api.example.com',
|
||||
cdnBase: 'https://cdn.example.com',
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
expect(window.__consentos.siteId).toBe('test-site-id');
|
||||
expect(window.__consentos.loaded).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
212
apps/banner/src/__tests__/reconsent.test.ts
Normal file
212
apps/banner/src/__tests__/reconsent.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import {
|
||||
hasConfigVersionChanged,
|
||||
isConsentExpired,
|
||||
needsReconsent,
|
||||
} from '../reconsent';
|
||||
import type { ConsentState, SiteConfig } from '../types';
|
||||
|
||||
/** Helper to build a minimal ConsentState for testing. */
|
||||
function makeConsent(overrides: Partial<ConsentState> = {}): ConsentState {
|
||||
return {
|
||||
visitorId: 'test-visitor-id',
|
||||
accepted: ['necessary', 'analytics'],
|
||||
rejected: ['marketing'],
|
||||
consentedAt: new Date().toISOString(),
|
||||
bannerVersion: '0.1.0',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Helper to build a minimal SiteConfig for testing. */
|
||||
function makeConfig(overrides: Partial<SiteConfig> = {}): SiteConfig {
|
||||
return {
|
||||
id: 'config-v1',
|
||||
site_id: 'site-123',
|
||||
blocking_mode: 'opt_in',
|
||||
regional_modes: null,
|
||||
tcf_enabled: false,
|
||||
gpp_enabled: false,
|
||||
gpp_supported_apis: [],
|
||||
gpc_enabled: true,
|
||||
gpc_jurisdictions: [],
|
||||
gpc_global_honour: false,
|
||||
gcm_enabled: true,
|
||||
gcm_default: null,
|
||||
shopify_privacy_enabled: false,
|
||||
banner_config: null,
|
||||
privacy_policy_url: null,
|
||||
terms_url: null,
|
||||
consent_expiry_days: 365,
|
||||
consent_group_id: null,
|
||||
ab_test: null,
|
||||
initiator_map: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('reconsent', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-06-15T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('isConsentExpired', () => {
|
||||
it('should return false when consent is within expiry period', () => {
|
||||
const consent = makeConsent({
|
||||
consentedAt: '2026-06-01T12:00:00Z', // 14 days ago
|
||||
});
|
||||
const config = makeConfig({ consent_expiry_days: 365 });
|
||||
|
||||
expect(isConsentExpired(consent, config)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when consent has expired', () => {
|
||||
const consent = makeConsent({
|
||||
consentedAt: '2025-01-01T12:00:00Z', // ~530 days ago
|
||||
});
|
||||
const config = makeConfig({ consent_expiry_days: 365 });
|
||||
|
||||
expect(isConsentExpired(consent, config)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for exactly expired consent', () => {
|
||||
// Consent given exactly 365 days ago + 1ms
|
||||
const consentDate = new Date('2025-06-15T11:59:59.999Z');
|
||||
const consent = makeConsent({ consentedAt: consentDate.toISOString() });
|
||||
const config = makeConfig({ consent_expiry_days: 365 });
|
||||
|
||||
expect(isConsentExpired(consent, config)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for invalid consentedAt date', () => {
|
||||
const consent = makeConsent({ consentedAt: 'not-a-date' });
|
||||
const config = makeConfig();
|
||||
|
||||
expect(isConsentExpired(consent, config)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle short expiry periods', () => {
|
||||
const consent = makeConsent({
|
||||
consentedAt: '2026-06-10T12:00:00Z', // 5 days ago
|
||||
});
|
||||
const config = makeConfig({ consent_expiry_days: 3 });
|
||||
|
||||
expect(isConsentExpired(consent, config)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasConfigVersionChanged', () => {
|
||||
it('should return false when versions match', () => {
|
||||
const consent = makeConsent({ configVersion: 'config-v1' });
|
||||
const config = makeConfig({ id: 'config-v1' });
|
||||
|
||||
expect(hasConfigVersionChanged(consent, config)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when versions differ', () => {
|
||||
const consent = makeConsent({ configVersion: 'config-v1' });
|
||||
const config = makeConfig({ id: 'config-v2' });
|
||||
|
||||
expect(hasConfigVersionChanged(consent, config)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when consent has no configVersion and config has an id', () => {
|
||||
const consent = makeConsent(); // no configVersion
|
||||
const config = makeConfig({ id: 'config-v1' });
|
||||
|
||||
expect(hasConfigVersionChanged(consent, config)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when consent has no configVersion and config id is empty', () => {
|
||||
const consent = makeConsent(); // no configVersion
|
||||
const config = makeConfig({ id: '' });
|
||||
|
||||
expect(hasConfigVersionChanged(consent, config)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsReconsent', () => {
|
||||
it('should return not required when all checks pass', () => {
|
||||
const consent = makeConsent({
|
||||
consentedAt: '2026-06-01T12:00:00Z',
|
||||
configVersion: 'config-v1',
|
||||
});
|
||||
const config = makeConfig({
|
||||
consent_expiry_days: 365,
|
||||
id: 'config-v1',
|
||||
});
|
||||
|
||||
const result = needsReconsent(consent, config);
|
||||
expect(result.required).toBe(false);
|
||||
expect(result.reasons).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect expired consent', () => {
|
||||
const consent = makeConsent({
|
||||
consentedAt: '2024-01-01T00:00:00Z',
|
||||
configVersion: 'config-v1',
|
||||
});
|
||||
const config = makeConfig({
|
||||
consent_expiry_days: 365,
|
||||
id: 'config-v1',
|
||||
});
|
||||
|
||||
const result = needsReconsent(consent, config);
|
||||
expect(result.required).toBe(true);
|
||||
expect(result.reasons).toContain('expired');
|
||||
});
|
||||
|
||||
it('should detect config version change', () => {
|
||||
const consent = makeConsent({
|
||||
consentedAt: '2026-06-01T12:00:00Z',
|
||||
configVersion: 'config-v1',
|
||||
});
|
||||
const config = makeConfig({
|
||||
consent_expiry_days: 365,
|
||||
id: 'config-v2',
|
||||
});
|
||||
|
||||
const result = needsReconsent(consent, config);
|
||||
expect(result.required).toBe(true);
|
||||
expect(result.reasons).toContain('config_changed');
|
||||
});
|
||||
|
||||
it('should report multiple reasons', () => {
|
||||
const consent = makeConsent({
|
||||
consentedAt: '2024-01-01T00:00:00Z', // expired
|
||||
configVersion: 'config-v1',
|
||||
});
|
||||
const config = makeConfig({
|
||||
consent_expiry_days: 365,
|
||||
id: 'config-v2', // config changed
|
||||
});
|
||||
|
||||
const result = needsReconsent(consent, config);
|
||||
expect(result.required).toBe(true);
|
||||
expect(result.reasons).toContain('expired');
|
||||
expect(result.reasons).toContain('config_changed');
|
||||
expect(result.reasons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should not require re-consent for fresh consent with matching config', () => {
|
||||
const consent = makeConsent({
|
||||
consentedAt: new Date().toISOString(), // just now
|
||||
configVersion: 'config-v1',
|
||||
});
|
||||
const config = makeConfig({
|
||||
consent_expiry_days: 365,
|
||||
id: 'config-v1',
|
||||
});
|
||||
|
||||
const result = needsReconsent(consent, config);
|
||||
expect(result.required).toBe(false);
|
||||
expect(result.reasons).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
493
apps/banner/src/__tests__/reporter.test.ts
Normal file
493
apps/banner/src/__tests__/reporter.test.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* Tests for client-side cookie reporter — CMP-23.
|
||||
*
|
||||
* Covers cookie parsing, storage enumeration, sampling, report building,
|
||||
* report sending, and the full reporter lifecycle.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type CookieReport,
|
||||
type DiscoveredCookie,
|
||||
type ReporterConfig,
|
||||
buildReport,
|
||||
collectAll,
|
||||
enumerateLocalStorage,
|
||||
enumerateSessionStorage,
|
||||
getObservedScripts,
|
||||
installScriptObserver,
|
||||
parseCookies,
|
||||
removeScriptObserver,
|
||||
reportNow,
|
||||
sendReport,
|
||||
shouldSample,
|
||||
startReporter,
|
||||
} from '../reporter';
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function makeConfig(
|
||||
overrides: Partial<ReporterConfig> = {},
|
||||
): ReporterConfig {
|
||||
return {
|
||||
siteId: 'test-site-id',
|
||||
apiBase: 'https://api.example.com/api/v1',
|
||||
sampleRate: 1.0, // Always sample in tests
|
||||
collectDelay: 0,
|
||||
includeLocalStorage: true,
|
||||
includeSessionStorage: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Cookie parsing ──────────────────────────────────────────────────
|
||||
|
||||
describe('parseCookies', () => {
|
||||
beforeEach(() => {
|
||||
// Clear all cookies
|
||||
document.cookie.split(';').forEach((c) => {
|
||||
const name = c.split('=')[0].trim();
|
||||
if (name) {
|
||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.cookie.split(';').forEach((c) => {
|
||||
const name = c.split('=')[0].trim();
|
||||
if (name) {
|
||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array when no cookies exist', () => {
|
||||
// After clearing, parseCookies should return empty or only empty entries
|
||||
const result = parseCookies();
|
||||
// Filter out any empty-name artefacts from jsdom
|
||||
const named = result.filter((c) => c.name.length > 0);
|
||||
expect(named).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse a single cookie', () => {
|
||||
document.cookie = '_ga=GA1.2.123456789.1234567890';
|
||||
const result = parseCookies();
|
||||
const ga = result.find((c) => c.name === '_ga');
|
||||
expect(ga).toBeDefined();
|
||||
expect(ga!.storage_type).toBe('cookie');
|
||||
expect(ga!.domain).toBe('localhost');
|
||||
expect(ga!.value_length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should parse multiple cookies', () => {
|
||||
document.cookie = '_ga=value1';
|
||||
document.cookie = '_gid=value2';
|
||||
const result = parseCookies();
|
||||
const names = result.map((c) => c.name);
|
||||
expect(names).toContain('_ga');
|
||||
expect(names).toContain('_gid');
|
||||
});
|
||||
|
||||
it('should handle cookies with equals in value', () => {
|
||||
document.cookie = 'data=key=value';
|
||||
const result = parseCookies();
|
||||
const data = result.find((c) => c.name === 'data');
|
||||
expect(data).toBeDefined();
|
||||
expect(data!.value_length).toBe('key=value'.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ── localStorage enumeration ────────────────────────────────────────
|
||||
|
||||
describe('enumerateLocalStorage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('should return empty array when localStorage is empty', () => {
|
||||
expect(enumerateLocalStorage()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should enumerate localStorage keys', () => {
|
||||
localStorage.setItem('analytics_id', 'abc123');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
const result = enumerateLocalStorage();
|
||||
expect(result).toHaveLength(2);
|
||||
const names = result.map((i) => i.name);
|
||||
expect(names).toContain('analytics_id');
|
||||
expect(names).toContain('theme');
|
||||
expect(result[0].storage_type).toBe('local_storage');
|
||||
});
|
||||
|
||||
it('should report correct value lengths', () => {
|
||||
localStorage.setItem('key', 'hello');
|
||||
const result = enumerateLocalStorage();
|
||||
expect(result[0].value_length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ── sessionStorage enumeration ──────────────────────────────────────
|
||||
|
||||
describe('enumerateSessionStorage', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('should return empty array when sessionStorage is empty', () => {
|
||||
expect(enumerateSessionStorage()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should enumerate sessionStorage keys', () => {
|
||||
sessionStorage.setItem('session_token', 'xyz');
|
||||
const result = enumerateSessionStorage();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('session_token');
|
||||
expect(result[0].storage_type).toBe('session_storage');
|
||||
});
|
||||
});
|
||||
|
||||
// ── collectAll ──────────────────────────────────────────────────────
|
||||
|
||||
describe('collectAll', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('should collect from all storage types', () => {
|
||||
document.cookie = '_ga=test';
|
||||
localStorage.setItem('ls_key', 'value');
|
||||
sessionStorage.setItem('ss_key', 'value');
|
||||
|
||||
const config = makeConfig();
|
||||
const result = collectAll(config);
|
||||
|
||||
const types = new Set(result.map((i) => i.storage_type));
|
||||
expect(types).toContain('cookie');
|
||||
expect(types).toContain('local_storage');
|
||||
expect(types).toContain('session_storage');
|
||||
});
|
||||
|
||||
it('should exclude localStorage when disabled', () => {
|
||||
localStorage.setItem('ls_key', 'value');
|
||||
|
||||
const config = makeConfig({ includeLocalStorage: false });
|
||||
const result = collectAll(config);
|
||||
|
||||
const hasLocal = result.some((i) => i.storage_type === 'local_storage');
|
||||
expect(hasLocal).toBe(false);
|
||||
});
|
||||
|
||||
it('should exclude sessionStorage when disabled', () => {
|
||||
sessionStorage.setItem('ss_key', 'value');
|
||||
|
||||
const config = makeConfig({ includeSessionStorage: false });
|
||||
const result = collectAll(config);
|
||||
|
||||
const hasSession = result.some(
|
||||
(i) => i.storage_type === 'session_storage',
|
||||
);
|
||||
expect(hasSession).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Report building ─────────────────────────────────────────────────
|
||||
|
||||
describe('buildReport', () => {
|
||||
it('should build a valid report', () => {
|
||||
const config = makeConfig();
|
||||
const cookies: DiscoveredCookie[] = [
|
||||
{
|
||||
name: '_ga',
|
||||
domain: 'example.com',
|
||||
storage_type: 'cookie',
|
||||
value_length: 30,
|
||||
},
|
||||
];
|
||||
|
||||
const report = buildReport(config, cookies);
|
||||
|
||||
expect(report.site_id).toBe('test-site-id');
|
||||
expect(report.cookies).toHaveLength(1);
|
||||
expect(report.collected_at).toBeTruthy();
|
||||
expect(report.page_url).toBeTruthy();
|
||||
expect(report.user_agent).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should include the current page URL', () => {
|
||||
const config = makeConfig();
|
||||
const report = buildReport(config, []);
|
||||
expect(report.page_url).toContain('localhost');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sampling ────────────────────────────────────────────────────────
|
||||
|
||||
describe('shouldSample', () => {
|
||||
it('should always sample at rate 1.0', () => {
|
||||
// Run 100 times — all should be true
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(shouldSample(1.0)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should never sample at rate 0.0', () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(shouldSample(0.0)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should sample approximately at the given rate', () => {
|
||||
// With 0.5, we'd expect roughly half
|
||||
let sampled = 0;
|
||||
const runs = 1000;
|
||||
for (let i = 0; i < runs; i++) {
|
||||
if (shouldSample(0.5)) sampled++;
|
||||
}
|
||||
// Allow generous margin (30-70%)
|
||||
expect(sampled).toBeGreaterThan(runs * 0.3);
|
||||
expect(sampled).toBeLessThan(runs * 0.7);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Report sending ──────────────────────────────────────────────────
|
||||
|
||||
describe('sendReport', () => {
|
||||
it('should use sendBeacon when available', async () => {
|
||||
const beaconMock = vi.fn().mockReturnValue(true);
|
||||
Object.defineProperty(navigator, 'sendBeacon', {
|
||||
value: beaconMock,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const report: CookieReport = {
|
||||
site_id: 'test',
|
||||
page_url: 'https://example.com',
|
||||
cookies: [],
|
||||
collected_at: new Date().toISOString(),
|
||||
user_agent: 'test-agent',
|
||||
};
|
||||
|
||||
const result = await sendReport(
|
||||
'https://api.example.com/api/v1',
|
||||
report,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(beaconMock).toHaveBeenCalledWith(
|
||||
'https://api.example.com/api/v1/scanner/report',
|
||||
expect.any(Blob),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to fetch when sendBeacon is unavailable', async () => {
|
||||
Object.defineProperty(navigator, 'sendBeacon', {
|
||||
value: undefined,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const report: CookieReport = {
|
||||
site_id: 'test',
|
||||
page_url: 'https://example.com',
|
||||
cookies: [],
|
||||
collected_at: new Date().toISOString(),
|
||||
user_agent: 'test-agent',
|
||||
};
|
||||
|
||||
const result = await sendReport(
|
||||
'https://api.example.com/api/v1',
|
||||
report,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.example.com/api/v1/scanner/report',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false on fetch failure', async () => {
|
||||
Object.defineProperty(navigator, 'sendBeacon', {
|
||||
value: undefined,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network error'));
|
||||
|
||||
const report: CookieReport = {
|
||||
site_id: 'test',
|
||||
page_url: 'https://example.com',
|
||||
cookies: [],
|
||||
collected_at: new Date().toISOString(),
|
||||
user_agent: 'test-agent',
|
||||
};
|
||||
|
||||
const result = await sendReport(
|
||||
'https://api.example.com/api/v1',
|
||||
report,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Script observer ─────────────────────────────────────────────────
|
||||
|
||||
describe('scriptObserver', () => {
|
||||
afterEach(() => {
|
||||
removeScriptObserver();
|
||||
});
|
||||
|
||||
it('should install and remove observer without errors', () => {
|
||||
// jsdom doesn't have full PerformanceObserver, but shouldn't throw
|
||||
installScriptObserver();
|
||||
expect(getObservedScripts()).toEqual([]);
|
||||
removeScriptObserver();
|
||||
expect(getObservedScripts()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should clear observed scripts on remove', () => {
|
||||
installScriptObserver();
|
||||
removeScriptObserver();
|
||||
expect(getObservedScripts()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── startReporter ───────────────────────────────────────────────────
|
||||
|
||||
describe('startReporter', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
removeScriptObserver();
|
||||
});
|
||||
|
||||
it('should not report when sample rate is 0', () => {
|
||||
const fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock;
|
||||
Object.defineProperty(navigator, 'sendBeacon', {
|
||||
value: undefined,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
startReporter({
|
||||
siteId: 'test',
|
||||
apiBase: 'https://api.example.com/api/v1',
|
||||
sampleRate: 0,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should report after delay when sampled', () => {
|
||||
document.cookie = '_test_reporter=value';
|
||||
const beaconMock = vi.fn().mockReturnValue(true);
|
||||
Object.defineProperty(navigator, 'sendBeacon', {
|
||||
value: beaconMock,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
startReporter({
|
||||
siteId: 'test',
|
||||
apiBase: 'https://api.example.com/api/v1',
|
||||
sampleRate: 1.0,
|
||||
collectDelay: 2000,
|
||||
});
|
||||
|
||||
// Before delay — should not have reported
|
||||
expect(beaconMock).not.toHaveBeenCalled();
|
||||
|
||||
// After delay — should report
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(beaconMock).toHaveBeenCalled();
|
||||
|
||||
// Clean up
|
||||
document.cookie =
|
||||
'_test_reporter=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||
});
|
||||
});
|
||||
|
||||
// ── reportNow ───────────────────────────────────────────────────────
|
||||
|
||||
describe('reportNow', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('should return null when no items found', async () => {
|
||||
// Clear cookies
|
||||
document.cookie.split(';').forEach((c) => {
|
||||
const name = c.split('=')[0].trim();
|
||||
if (name) {
|
||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||
}
|
||||
});
|
||||
|
||||
const result = await reportNow({
|
||||
siteId: 'test',
|
||||
apiBase: 'https://api.example.com/api/v1',
|
||||
includeLocalStorage: false,
|
||||
includeSessionStorage: false,
|
||||
});
|
||||
|
||||
// May be null if no cookies remain, or a report if jsdom has residual cookies
|
||||
if (result !== null) {
|
||||
expect(result.cookies.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should collect and send a report', async () => {
|
||||
document.cookie = '_report_test=value';
|
||||
const beaconMock = vi.fn().mockReturnValue(true);
|
||||
Object.defineProperty(navigator, 'sendBeacon', {
|
||||
value: beaconMock,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const result = await reportNow({
|
||||
siteId: 'my-site',
|
||||
apiBase: 'https://api.example.com/api/v1',
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.site_id).toBe('my-site');
|
||||
expect(result!.cookies.length).toBeGreaterThan(0);
|
||||
expect(beaconMock).toHaveBeenCalled();
|
||||
|
||||
// Clean up
|
||||
document.cookie =
|
||||
'_report_test=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||
});
|
||||
});
|
||||
112
apps/banner/src/__tests__/shopify.test.ts
Normal file
112
apps/banner/src/__tests__/shopify.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { buildShopifyConsent, isShopifyPrivacyAvailable, updateShopifyConsent } from '../shopify';
|
||||
import type { CategorySlug } from '../types';
|
||||
|
||||
describe('shopify', () => {
|
||||
const mockSetTrackingConsent = vi.fn();
|
||||
const mockCurrentVisitorConsent = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
(window as any).Shopify = {
|
||||
customerPrivacy: {
|
||||
setTrackingConsent: mockSetTrackingConsent,
|
||||
currentVisitorConsent: mockCurrentVisitorConsent,
|
||||
analyticsProcessingAllowed: () => false,
|
||||
marketingAllowed: () => false,
|
||||
preferencesProcessingAllowed: () => false,
|
||||
saleOfDataAllowed: () => false,
|
||||
getRegion: () => 'CA',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).Shopify;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('isShopifyPrivacyAvailable', () => {
|
||||
it('returns true when Shopify API is present', () => {
|
||||
expect(isShopifyPrivacyAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when Shopify is not on window', () => {
|
||||
delete (window as any).Shopify;
|
||||
expect(isShopifyPrivacyAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when customerPrivacy is missing', () => {
|
||||
(window as any).Shopify = {};
|
||||
expect(isShopifyPrivacyAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildShopifyConsent', () => {
|
||||
it('maps accept all to all yes', () => {
|
||||
const accepted: CategorySlug[] = ['necessary', 'functional', 'analytics', 'marketing', 'personalisation'];
|
||||
const result = buildShopifyConsent(accepted);
|
||||
expect(result).toEqual({
|
||||
preferences: 'yes',
|
||||
analytics: 'yes',
|
||||
marketing: 'yes',
|
||||
sale_of_data: 'yes',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps reject all (necessary only) to all no', () => {
|
||||
const accepted: CategorySlug[] = ['necessary'];
|
||||
const result = buildShopifyConsent(accepted);
|
||||
expect(result).toEqual({
|
||||
preferences: 'no',
|
||||
analytics: 'no',
|
||||
marketing: 'no',
|
||||
sale_of_data: 'no',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps functional to preferences', () => {
|
||||
const accepted: CategorySlug[] = ['necessary', 'functional'];
|
||||
const result = buildShopifyConsent(accepted);
|
||||
expect(result.preferences).toBe('yes');
|
||||
expect(result.analytics).toBe('no');
|
||||
expect(result.marketing).toBe('no');
|
||||
});
|
||||
|
||||
it('maps personalisation to sale_of_data', () => {
|
||||
const accepted: CategorySlug[] = ['necessary', 'personalisation'];
|
||||
const result = buildShopifyConsent(accepted);
|
||||
expect(result.sale_of_data).toBe('yes');
|
||||
expect(result.marketing).toBe('no');
|
||||
});
|
||||
|
||||
it('maps marketing to both marketing and sale_of_data', () => {
|
||||
const accepted: CategorySlug[] = ['necessary', 'marketing'];
|
||||
const result = buildShopifyConsent(accepted);
|
||||
expect(result.marketing).toBe('yes');
|
||||
expect(result.sale_of_data).toBe('yes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateShopifyConsent', () => {
|
||||
it('calls setTrackingConsent with mapped values', () => {
|
||||
const accepted: CategorySlug[] = ['necessary', 'analytics', 'marketing'];
|
||||
updateShopifyConsent(accepted);
|
||||
|
||||
expect(mockSetTrackingConsent).toHaveBeenCalledWith(
|
||||
{
|
||||
preferences: 'no',
|
||||
analytics: 'yes',
|
||||
marketing: 'yes',
|
||||
sale_of_data: 'yes',
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when Shopify API is not available', () => {
|
||||
delete (window as any).Shopify;
|
||||
updateShopifyConsent(['necessary', 'analytics']);
|
||||
expect(mockSetTrackingConsent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
856
apps/banner/src/__tests__/tcf.test.ts
Normal file
856
apps/banner/src/__tests__/tcf.test.ts
Normal file
@@ -0,0 +1,856 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
BitReader,
|
||||
BitWriter,
|
||||
RestrictionType,
|
||||
base64urlToBytes,
|
||||
bytesToBase64url,
|
||||
createTCModel,
|
||||
decodeTCString,
|
||||
decisecondsToMs,
|
||||
encodeTCString,
|
||||
getTcString,
|
||||
installTcfApi,
|
||||
msToDeciseconds,
|
||||
removeTcfApi,
|
||||
setTcfDisplayStatus,
|
||||
updateTcfConsent,
|
||||
} from '../tcf';
|
||||
import type { TCModel, TcfApiCallback } from '../tcf';
|
||||
|
||||
// ── BitWriter / BitReader ────────────────────────────────────────────
|
||||
|
||||
describe('BitWriter', () => {
|
||||
it('writes single bits correctly', () => {
|
||||
const w = new BitWriter();
|
||||
w.writeBool(true);
|
||||
w.writeBool(false);
|
||||
w.writeBool(true);
|
||||
w.writeBool(true);
|
||||
w.writeBool(false);
|
||||
w.writeBool(false);
|
||||
w.writeBool(true);
|
||||
w.writeBool(false);
|
||||
const bytes = w.toBytes();
|
||||
expect(bytes.length).toBe(1);
|
||||
// 10110010 = 0xB2 = 178
|
||||
expect(bytes[0]).toBe(0b10110010);
|
||||
});
|
||||
|
||||
it('writes multi-bit integers', () => {
|
||||
const w = new BitWriter();
|
||||
w.writeInt(2, 6); // 000010
|
||||
const bytes = w.toBytes();
|
||||
// 000010 + 00 (padding) = 00001000 = 8
|
||||
expect(bytes[0]).toBe(0b00001000);
|
||||
});
|
||||
|
||||
it('writes integers across byte boundaries', () => {
|
||||
const w = new BitWriter();
|
||||
w.writeInt(0b11111111, 8);
|
||||
w.writeInt(0b1010, 4);
|
||||
const bytes = w.toBytes();
|
||||
expect(bytes.length).toBe(2);
|
||||
expect(bytes[0]).toBe(0xff);
|
||||
// 1010 + 0000 (padding) = 10100000
|
||||
expect(bytes[1]).toBe(0b10100000);
|
||||
});
|
||||
|
||||
it('writes a bitfield from a Set', () => {
|
||||
const w = new BitWriter();
|
||||
w.writeBitfield(new Set([1, 3, 5]), 8);
|
||||
const bytes = w.toBytes();
|
||||
// bits: 1 0 1 0 1 0 0 0 = 0xA8
|
||||
expect(bytes[0]).toBe(0b10101000);
|
||||
});
|
||||
|
||||
it('writes two-letter codes', () => {
|
||||
const w = new BitWriter();
|
||||
w.writeLetters('EN');
|
||||
const bytes = w.toBytes();
|
||||
// E=4 (000100), N=13 (001101) → 000100 001101 → 00010000 1101(0000)
|
||||
const r = new BitReader(bytes);
|
||||
expect(r.readInt(6)).toBe(4); // E
|
||||
expect(r.readInt(6)).toBe(13); // N
|
||||
});
|
||||
|
||||
it('handles empty writes', () => {
|
||||
const w = new BitWriter();
|
||||
const bytes = w.toBytes();
|
||||
expect(bytes.length).toBe(0);
|
||||
});
|
||||
|
||||
it('pads incomplete last byte', () => {
|
||||
const w = new BitWriter();
|
||||
w.writeBool(true);
|
||||
const bytes = w.toBytes();
|
||||
expect(bytes.length).toBe(1);
|
||||
// 1 + 0000000 (padding) = 10000000
|
||||
expect(bytes[0]).toBe(0b10000000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BitReader', () => {
|
||||
it('reads single bits', () => {
|
||||
const r = new BitReader(new Uint8Array([0b10110010]));
|
||||
expect(r.readBool()).toBe(true);
|
||||
expect(r.readBool()).toBe(false);
|
||||
expect(r.readBool()).toBe(true);
|
||||
expect(r.readBool()).toBe(true);
|
||||
expect(r.readBool()).toBe(false);
|
||||
expect(r.readBool()).toBe(false);
|
||||
expect(r.readBool()).toBe(true);
|
||||
expect(r.readBool()).toBe(false);
|
||||
});
|
||||
|
||||
it('reads multi-bit integers', () => {
|
||||
const r = new BitReader(new Uint8Array([0b00001000]));
|
||||
expect(r.readInt(6)).toBe(2);
|
||||
});
|
||||
|
||||
it('reads across byte boundaries', () => {
|
||||
const r = new BitReader(new Uint8Array([0xff, 0b10100000]));
|
||||
expect(r.readInt(8)).toBe(255);
|
||||
expect(r.readInt(4)).toBe(0b1010);
|
||||
});
|
||||
|
||||
it('reads a bitfield into a Set', () => {
|
||||
const r = new BitReader(new Uint8Array([0b10101000]));
|
||||
const ids = r.readBitfield(8);
|
||||
expect(ids).toEqual(new Set([1, 3, 5]));
|
||||
});
|
||||
|
||||
it('reads two-letter codes', () => {
|
||||
// E=4 (000100), N=13 (001101) → 00010000 11010000
|
||||
const r = new BitReader(new Uint8Array([0b00010000, 0b11010000]));
|
||||
expect(r.readLetters()).toBe('EN');
|
||||
});
|
||||
|
||||
it('hasRemaining checks available bits', () => {
|
||||
const r = new BitReader(new Uint8Array([0xff]));
|
||||
expect(r.hasRemaining(8)).toBe(true);
|
||||
expect(r.hasRemaining(9)).toBe(false);
|
||||
r.readInt(4);
|
||||
expect(r.hasRemaining(4)).toBe(true);
|
||||
expect(r.hasRemaining(5)).toBe(false);
|
||||
});
|
||||
|
||||
it('reads zero when past end of buffer', () => {
|
||||
const r = new BitReader(new Uint8Array([0xff]));
|
||||
r.readInt(8); // consume all
|
||||
expect(r.readInt(4)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Base64url ────────────────────────────────────────────────────────
|
||||
|
||||
describe('base64url encoding', () => {
|
||||
it('round-trips bytes correctly', () => {
|
||||
const original = new Uint8Array([0, 1, 2, 127, 128, 255]);
|
||||
const encoded = bytesToBase64url(original);
|
||||
const decoded = base64urlToBytes(encoded);
|
||||
expect(decoded).toEqual(original);
|
||||
});
|
||||
|
||||
it('encodes empty array', () => {
|
||||
expect(bytesToBase64url(new Uint8Array([]))).toBe('');
|
||||
});
|
||||
|
||||
it('decodes empty string', () => {
|
||||
expect(base64urlToBytes('')).toEqual(new Uint8Array([]));
|
||||
});
|
||||
|
||||
it('produces URL-safe characters (no +, /, =)', () => {
|
||||
const bytes = new Uint8Array(256);
|
||||
for (let i = 0; i < 256; i++) bytes[i] = i;
|
||||
const encoded = bytesToBase64url(bytes);
|
||||
expect(encoded).not.toContain('+');
|
||||
expect(encoded).not.toContain('/');
|
||||
expect(encoded).not.toContain('=');
|
||||
});
|
||||
|
||||
it('round-trips single byte', () => {
|
||||
const original = new Uint8Array([42]);
|
||||
expect(base64urlToBytes(bytesToBase64url(original))).toEqual(original);
|
||||
});
|
||||
|
||||
it('round-trips two bytes', () => {
|
||||
const original = new Uint8Array([200, 100]);
|
||||
expect(base64urlToBytes(bytesToBase64url(original))).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Timestamp helpers ────────────────────────────────────────────────
|
||||
|
||||
describe('timestamp conversion', () => {
|
||||
it('converts ms to deciseconds', () => {
|
||||
expect(msToDeciseconds(1000)).toBe(10);
|
||||
expect(msToDeciseconds(100)).toBe(1);
|
||||
expect(msToDeciseconds(150)).toBe(2); // rounds
|
||||
});
|
||||
|
||||
it('converts deciseconds to ms', () => {
|
||||
expect(decisecondsToMs(10)).toBe(1000);
|
||||
expect(decisecondsToMs(1)).toBe(100);
|
||||
});
|
||||
|
||||
it('round-trips approximately', () => {
|
||||
const now = Date.now();
|
||||
const ds = msToDeciseconds(now);
|
||||
const back = decisecondsToMs(ds);
|
||||
// Within 100ms accuracy (one decisecond)
|
||||
expect(Math.abs(back - now)).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createTCModel ────────────────────────────────────────────────────
|
||||
|
||||
describe('createTCModel', () => {
|
||||
it('creates a model with defaults', () => {
|
||||
const model = createTCModel();
|
||||
expect(model.version).toBe(2);
|
||||
expect(model.tcfPolicyVersion).toBe(4);
|
||||
expect(model.isServiceSpecific).toBe(true);
|
||||
expect(model.consentLanguage).toBe('EN');
|
||||
expect(model.publisherCC).toBe('GB');
|
||||
expect(model.purposeConsents.size).toBe(0);
|
||||
expect(model.vendorConsents.size).toBe(0);
|
||||
});
|
||||
|
||||
it('accepts overrides', () => {
|
||||
const model = createTCModel({
|
||||
cmpId: 42,
|
||||
consentLanguage: 'FR',
|
||||
purposeConsents: new Set([1, 2, 3]),
|
||||
});
|
||||
expect(model.cmpId).toBe(42);
|
||||
expect(model.consentLanguage).toBe('FR');
|
||||
expect(model.purposeConsents).toEqual(new Set([1, 2, 3]));
|
||||
// Defaults still apply
|
||||
expect(model.version).toBe(2);
|
||||
});
|
||||
|
||||
it('sets created and lastUpdated to now', () => {
|
||||
const before = msToDeciseconds(Date.now());
|
||||
const model = createTCModel();
|
||||
const after = msToDeciseconds(Date.now());
|
||||
expect(model.created).toBeGreaterThanOrEqual(before);
|
||||
expect(model.created).toBeLessThanOrEqual(after);
|
||||
expect(model.lastUpdated).toBe(model.created);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Encode / Decode round-trip ───────────────────────────────────────
|
||||
|
||||
describe('encodeTCString / decodeTCString', () => {
|
||||
it('round-trips a minimal model', () => {
|
||||
const model = createTCModel({
|
||||
created: 100000,
|
||||
lastUpdated: 100000,
|
||||
cmpId: 10,
|
||||
cmpVersion: 2,
|
||||
});
|
||||
|
||||
const tcString = encodeTCString(model);
|
||||
expect(typeof tcString).toBe('string');
|
||||
expect(tcString.length).toBeGreaterThan(0);
|
||||
|
||||
const decoded = decodeTCString(tcString);
|
||||
expect(decoded.version).toBe(2);
|
||||
expect(decoded.created).toBe(100000);
|
||||
expect(decoded.lastUpdated).toBe(100000);
|
||||
expect(decoded.cmpId).toBe(10);
|
||||
expect(decoded.cmpVersion).toBe(2);
|
||||
expect(decoded.consentLanguage).toBe('EN');
|
||||
expect(decoded.publisherCC).toBe('GB');
|
||||
expect(decoded.isServiceSpecific).toBe(true);
|
||||
expect(decoded.tcfPolicyVersion).toBe(4);
|
||||
});
|
||||
|
||||
it('round-trips purpose consents', () => {
|
||||
const model = createTCModel({
|
||||
created: 200000,
|
||||
lastUpdated: 200000,
|
||||
purposeConsents: new Set([1, 2, 3, 7, 10]),
|
||||
purposeLegitimateInterests: new Set([2, 4, 6]),
|
||||
});
|
||||
|
||||
const decoded = decodeTCString(encodeTCString(model));
|
||||
expect(decoded.purposeConsents).toEqual(new Set([1, 2, 3, 7, 10]));
|
||||
expect(decoded.purposeLegitimateInterests).toEqual(new Set([2, 4, 6]));
|
||||
});
|
||||
|
||||
it('round-trips vendor consents', () => {
|
||||
const model = createTCModel({
|
||||
created: 300000,
|
||||
lastUpdated: 300000,
|
||||
vendorConsents: new Set([1, 5, 10, 100]),
|
||||
vendorLegitimateInterests: new Set([2, 50]),
|
||||
});
|
||||
|
||||
const decoded = decodeTCString(encodeTCString(model));
|
||||
expect(decoded.vendorConsents).toEqual(new Set([1, 5, 10, 100]));
|
||||
expect(decoded.vendorLegitimateInterests).toEqual(new Set([2, 50]));
|
||||
});
|
||||
|
||||
it('round-trips special feature opt-ins', () => {
|
||||
const model = createTCModel({
|
||||
created: 400000,
|
||||
lastUpdated: 400000,
|
||||
specialFeatureOptIns: new Set([1, 2]),
|
||||
});
|
||||
|
||||
const decoded = decodeTCString(encodeTCString(model));
|
||||
expect(decoded.specialFeatureOptIns).toEqual(new Set([1, 2]));
|
||||
});
|
||||
|
||||
it('round-trips consent language and publisher CC', () => {
|
||||
const model = createTCModel({
|
||||
created: 500000,
|
||||
lastUpdated: 500000,
|
||||
consentLanguage: 'FR',
|
||||
publisherCC: 'DE',
|
||||
});
|
||||
|
||||
const decoded = decodeTCString(encodeTCString(model));
|
||||
expect(decoded.consentLanguage).toBe('FR');
|
||||
expect(decoded.publisherCC).toBe('DE');
|
||||
});
|
||||
|
||||
it('round-trips boolean flags', () => {
|
||||
const model = createTCModel({
|
||||
created: 600000,
|
||||
lastUpdated: 600000,
|
||||
isServiceSpecific: false,
|
||||
useNonStandardTexts: true,
|
||||
purposeOneTreatment: true,
|
||||
});
|
||||
|
||||
const decoded = decodeTCString(encodeTCString(model));
|
||||
expect(decoded.isServiceSpecific).toBe(false);
|
||||
expect(decoded.useNonStandardTexts).toBe(true);
|
||||
expect(decoded.purposeOneTreatment).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trips publisher restrictions', () => {
|
||||
const model = createTCModel({
|
||||
created: 700000,
|
||||
lastUpdated: 700000,
|
||||
publisherRestrictions: [
|
||||
{
|
||||
purposeId: 1,
|
||||
restrictionType: RestrictionType.REQUIRE_CONSENT,
|
||||
vendorIds: new Set([10, 20, 30]),
|
||||
},
|
||||
{
|
||||
purposeId: 3,
|
||||
restrictionType: RestrictionType.NOT_ALLOWED,
|
||||
vendorIds: new Set([5]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const decoded = decodeTCString(encodeTCString(model));
|
||||
expect(decoded.publisherRestrictions.length).toBe(2);
|
||||
expect(decoded.publisherRestrictions[0].purposeId).toBe(1);
|
||||
expect(decoded.publisherRestrictions[0].restrictionType).toBe(
|
||||
RestrictionType.REQUIRE_CONSENT
|
||||
);
|
||||
expect(decoded.publisherRestrictions[0].vendorIds).toEqual(new Set([10, 20, 30]));
|
||||
expect(decoded.publisherRestrictions[1].purposeId).toBe(3);
|
||||
expect(decoded.publisherRestrictions[1].vendorIds).toEqual(new Set([5]));
|
||||
});
|
||||
|
||||
it('handles empty vendor sets', () => {
|
||||
const model = createTCModel({
|
||||
created: 800000,
|
||||
lastUpdated: 800000,
|
||||
vendorConsents: new Set(),
|
||||
vendorLegitimateInterests: new Set(),
|
||||
});
|
||||
|
||||
const decoded = decodeTCString(encodeTCString(model));
|
||||
expect(decoded.vendorConsents.size).toBe(0);
|
||||
expect(decoded.vendorLegitimateInterests.size).toBe(0);
|
||||
});
|
||||
|
||||
it('handles no publisher restrictions', () => {
|
||||
const model = createTCModel({
|
||||
created: 900000,
|
||||
lastUpdated: 900000,
|
||||
publisherRestrictions: [],
|
||||
});
|
||||
|
||||
const decoded = decodeTCString(encodeTCString(model));
|
||||
expect(decoded.publisherRestrictions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('round-trips a fully populated model', () => {
|
||||
const model = createTCModel({
|
||||
created: 1000000,
|
||||
lastUpdated: 1000001,
|
||||
cmpId: 300,
|
||||
cmpVersion: 5,
|
||||
consentScreen: 2,
|
||||
consentLanguage: 'DE',
|
||||
vendorListVersion: 150,
|
||||
tcfPolicyVersion: 4,
|
||||
isServiceSpecific: false,
|
||||
useNonStandardTexts: false,
|
||||
specialFeatureOptIns: new Set([1, 2]),
|
||||
purposeConsents: new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
|
||||
purposeLegitimateInterests: new Set([2, 7, 9, 10]),
|
||||
purposeOneTreatment: true,
|
||||
publisherCC: 'FR',
|
||||
vendorConsents: new Set([1, 2, 3, 10, 25, 50, 100, 200, 500]),
|
||||
vendorLegitimateInterests: new Set([1, 10, 50]),
|
||||
publisherRestrictions: [
|
||||
{
|
||||
purposeId: 2,
|
||||
restrictionType: RestrictionType.REQUIRE_LEGITIMATE_INTEREST,
|
||||
vendorIds: new Set([100, 101, 102]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const tcString = encodeTCString(model);
|
||||
const decoded = decodeTCString(tcString);
|
||||
|
||||
expect(decoded.version).toBe(2);
|
||||
expect(decoded.created).toBe(1000000);
|
||||
expect(decoded.lastUpdated).toBe(1000001);
|
||||
expect(decoded.cmpId).toBe(300);
|
||||
expect(decoded.cmpVersion).toBe(5);
|
||||
expect(decoded.consentScreen).toBe(2);
|
||||
expect(decoded.consentLanguage).toBe('DE');
|
||||
expect(decoded.vendorListVersion).toBe(150);
|
||||
expect(decoded.tcfPolicyVersion).toBe(4);
|
||||
expect(decoded.isServiceSpecific).toBe(false);
|
||||
expect(decoded.purposeConsents).toEqual(new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]));
|
||||
expect(decoded.purposeLegitimateInterests).toEqual(new Set([2, 7, 9, 10]));
|
||||
expect(decoded.purposeOneTreatment).toBe(true);
|
||||
expect(decoded.publisherCC).toBe('FR');
|
||||
expect(decoded.vendorConsents).toEqual(
|
||||
new Set([1, 2, 3, 10, 25, 50, 100, 200, 500])
|
||||
);
|
||||
expect(decoded.vendorLegitimateInterests).toEqual(new Set([1, 10, 50]));
|
||||
expect(decoded.specialFeatureOptIns).toEqual(new Set([1, 2]));
|
||||
expect(decoded.publisherRestrictions[0].vendorIds).toEqual(
|
||||
new Set([100, 101, 102])
|
||||
);
|
||||
});
|
||||
|
||||
it('only parses the core segment when dots are present', () => {
|
||||
const model = createTCModel({ created: 1100000, lastUpdated: 1100000 });
|
||||
const tcString = encodeTCString(model);
|
||||
// Append a fake disclosed vendors segment
|
||||
const withSegments = `${tcString}.FAKE_SEGMENT`;
|
||||
const decoded = decodeTCString(withSegments);
|
||||
expect(decoded.version).toBe(2);
|
||||
expect(decoded.created).toBe(1100000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── __tcfapi interface ───────────────────────────────────────────────
|
||||
|
||||
describe('__tcfapi interface', () => {
|
||||
beforeEach(() => {
|
||||
removeTcfApi();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
removeTcfApi();
|
||||
});
|
||||
|
||||
describe('installTcfApi', () => {
|
||||
it('installs __tcfapi on window', () => {
|
||||
installTcfApi(42, 1);
|
||||
expect(typeof window.__tcfapi).toBe('function');
|
||||
});
|
||||
|
||||
it('processes queued calls from the stub', () => {
|
||||
const queue: unknown[][] = [];
|
||||
window.__tcfapiQueue = queue;
|
||||
|
||||
const callback = vi.fn();
|
||||
queue.push(['ping', 2, callback]);
|
||||
|
||||
installTcfApi(42, 1);
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cmpLoaded: true }),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the queue after processing', () => {
|
||||
const queue: unknown[][] = [['ping', 2, vi.fn()]];
|
||||
window.__tcfapiQueue = queue;
|
||||
|
||||
installTcfApi(42, 1);
|
||||
expect(queue.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ping command', () => {
|
||||
it('returns CMP status', () => {
|
||||
installTcfApi(42, 3);
|
||||
const callback = vi.fn();
|
||||
|
||||
const api = window.__tcfapi as Function;
|
||||
api('ping', 2, callback);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gdprApplies: true,
|
||||
cmpLoaded: true,
|
||||
cmpStatus: 'loaded',
|
||||
displayStatus: 'hidden',
|
||||
apiVersion: '2.2',
|
||||
cmpVersion: 3,
|
||||
cmpId: 42,
|
||||
tcfPolicyVersion: 4,
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('respects gdprApplies parameter', () => {
|
||||
installTcfApi(42, 1, false);
|
||||
const callback = vi.fn();
|
||||
|
||||
const api = window.__tcfapi as Function;
|
||||
api('ping', 2, callback);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gdprApplies: false }),
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTCData command', () => {
|
||||
it('returns cmpuishown when no consent', () => {
|
||||
installTcfApi(42, 1);
|
||||
const callback = vi.fn();
|
||||
|
||||
const api = window.__tcfapi as Function;
|
||||
api('getTCData', 2, callback);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
eventStatus: 'cmpuishown',
|
||||
cmpId: 42,
|
||||
tcString: '',
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('returns tcloaded after consent is set', () => {
|
||||
installTcfApi(42, 1);
|
||||
|
||||
const model = createTCModel({
|
||||
created: 100000,
|
||||
lastUpdated: 100000,
|
||||
cmpId: 42,
|
||||
purposeConsents: new Set([1, 2]),
|
||||
});
|
||||
updateTcfConsent(model);
|
||||
|
||||
const callback = vi.fn();
|
||||
const api = window.__tcfapi as Function;
|
||||
api('getTCData', 2, callback);
|
||||
|
||||
const result = callback.mock.calls[0][0];
|
||||
expect(result.eventStatus).toBe('tcloaded');
|
||||
expect(result.tcString).toBeTruthy();
|
||||
expect(result.purpose.consents['1']).toBe(true);
|
||||
expect(result.purpose.consents['2']).toBe(true);
|
||||
expect(result.purpose.consents['3']).toBe(false);
|
||||
});
|
||||
|
||||
it('includes vendor consent data', () => {
|
||||
installTcfApi(42, 1);
|
||||
|
||||
const model = createTCModel({
|
||||
created: 100000,
|
||||
lastUpdated: 100000,
|
||||
vendorConsents: new Set([1, 5, 10]),
|
||||
vendorLegitimateInterests: new Set([2, 5]),
|
||||
});
|
||||
updateTcfConsent(model);
|
||||
|
||||
const callback = vi.fn();
|
||||
const api = window.__tcfapi as Function;
|
||||
api('getTCData', 2, callback);
|
||||
|
||||
const result = callback.mock.calls[0][0];
|
||||
expect(result.vendor.consents['1']).toBe(true);
|
||||
expect(result.vendor.consents['5']).toBe(true);
|
||||
expect(result.vendor.consents['10']).toBe(true);
|
||||
expect(result.vendor.consents['2']).toBe(false);
|
||||
expect(result.vendor.legitimateInterests['2']).toBe(true);
|
||||
expect(result.vendor.legitimateInterests['5']).toBe(true);
|
||||
});
|
||||
|
||||
it('includes special feature opt-in data', () => {
|
||||
installTcfApi(42, 1);
|
||||
|
||||
const model = createTCModel({
|
||||
created: 100000,
|
||||
lastUpdated: 100000,
|
||||
specialFeatureOptIns: new Set([1, 2]),
|
||||
});
|
||||
updateTcfConsent(model);
|
||||
|
||||
const callback = vi.fn();
|
||||
const api = window.__tcfapi as Function;
|
||||
api('getTCData', 2, callback);
|
||||
|
||||
const result = callback.mock.calls[0][0];
|
||||
expect(result.specialFeatureOptins['1']).toBe(true);
|
||||
expect(result.specialFeatureOptins['2']).toBe(true);
|
||||
expect(result.specialFeatureOptins['3']).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addEventListener command', () => {
|
||||
it('assigns a listener ID and returns current TC data', () => {
|
||||
installTcfApi(42, 1);
|
||||
|
||||
const callback = vi.fn();
|
||||
const api = window.__tcfapi as Function;
|
||||
api('addEventListener', 2, callback);
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
const result = callback.mock.calls[0][0];
|
||||
expect(result.listenerId).toBe(1);
|
||||
expect(result.eventStatus).toBe('cmpuishown');
|
||||
});
|
||||
|
||||
it('notifies listeners when consent is updated', () => {
|
||||
installTcfApi(42, 1);
|
||||
|
||||
const listener = vi.fn();
|
||||
const api = window.__tcfapi as Function;
|
||||
api('addEventListener', 2, listener);
|
||||
|
||||
// Initial call
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
|
||||
// Update consent
|
||||
const model = createTCModel({
|
||||
created: 100000,
|
||||
lastUpdated: 100000,
|
||||
purposeConsents: new Set([1]),
|
||||
});
|
||||
updateTcfConsent(model);
|
||||
|
||||
// Should be called again with useractioncomplete
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
const updateResult = listener.mock.calls[1][0];
|
||||
expect(updateResult.eventStatus).toBe('useractioncomplete');
|
||||
expect(updateResult.tcString).toBeTruthy();
|
||||
});
|
||||
|
||||
it('assigns incrementing listener IDs', () => {
|
||||
installTcfApi(42, 1);
|
||||
|
||||
const cb1 = vi.fn();
|
||||
const cb2 = vi.fn();
|
||||
const api = window.__tcfapi as Function;
|
||||
api('addEventListener', 2, cb1);
|
||||
api('addEventListener', 2, cb2);
|
||||
|
||||
expect(cb1.mock.calls[0][0].listenerId).toBe(1);
|
||||
expect(cb2.mock.calls[0][0].listenerId).toBe(2);
|
||||
});
|
||||
|
||||
it('swallows errors in listener callbacks', () => {
|
||||
installTcfApi(42, 1);
|
||||
|
||||
const badListener = vi.fn(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const goodListener = vi.fn();
|
||||
|
||||
const api = window.__tcfapi as Function;
|
||||
api('addEventListener', 2, badListener);
|
||||
api('addEventListener', 2, goodListener);
|
||||
|
||||
// Update consent - should not throw
|
||||
const model = createTCModel({ created: 100000, lastUpdated: 100000 });
|
||||
expect(() => updateTcfConsent(model)).not.toThrow();
|
||||
|
||||
// Good listener still called
|
||||
expect(goodListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeEventListener command', () => {
|
||||
it('removes a listener by ID', () => {
|
||||
installTcfApi(42, 1);
|
||||
|
||||
const listener = vi.fn();
|
||||
const api = window.__tcfapi as Function;
|
||||
api('addEventListener', 2, listener);
|
||||
|
||||
const listenerId = listener.mock.calls[0][0].listenerId;
|
||||
|
||||
const removeCallback = vi.fn();
|
||||
api('removeEventListener', 2, removeCallback, listenerId);
|
||||
expect(removeCallback).toHaveBeenCalledWith(true, true);
|
||||
|
||||
// Listener should not be called on updates
|
||||
listener.mockClear();
|
||||
updateTcfConsent(createTCModel({ created: 100000, lastUpdated: 100000 }));
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false for non-existent listener ID', () => {
|
||||
installTcfApi(42, 1);
|
||||
|
||||
const callback = vi.fn();
|
||||
const api = window.__tcfapi as Function;
|
||||
api('removeEventListener', 2, callback, 999);
|
||||
expect(callback).toHaveBeenCalledWith(false, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('version checking', () => {
|
||||
it('rejects non-v2 API calls', () => {
|
||||
installTcfApi(42, 1);
|
||||
|
||||
const callback = vi.fn();
|
||||
const api = window.__tcfapi as Function;
|
||||
api('ping', 1, callback);
|
||||
expect(callback).toHaveBeenCalledWith(false, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown commands', () => {
|
||||
it('calls back with false for unknown commands', () => {
|
||||
installTcfApi(42, 1);
|
||||
|
||||
const callback = vi.fn();
|
||||
const api = window.__tcfapi as Function;
|
||||
api('unknownCommand', 2, callback);
|
||||
expect(callback).toHaveBeenCalledWith(false, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninitialised state', () => {
|
||||
it('calls back with false when API not installed', () => {
|
||||
// Do NOT call installTcfApi — directly test the handler via a mock
|
||||
// Since removeTcfApi is called in beforeEach, apiState is null
|
||||
// We need to call installTcfApi then removeTcfApi to reset, then
|
||||
// try calling a cached reference (but __tcfapi is deleted).
|
||||
// Instead, test that getTcString returns empty when uninitialised.
|
||||
expect(getTcString()).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateTcfConsent ─────────────────────────────────────────────────
|
||||
|
||||
describe('updateTcfConsent', () => {
|
||||
beforeEach(() => {
|
||||
removeTcfApi();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
removeTcfApi();
|
||||
});
|
||||
|
||||
it('returns a valid TC string', () => {
|
||||
installTcfApi(42, 1);
|
||||
const model = createTCModel({
|
||||
created: 100000,
|
||||
lastUpdated: 100000,
|
||||
cmpId: 42,
|
||||
purposeConsents: new Set([1, 2, 3]),
|
||||
});
|
||||
|
||||
const tcString = updateTcfConsent(model);
|
||||
expect(typeof tcString).toBe('string');
|
||||
expect(tcString.length).toBeGreaterThan(0);
|
||||
|
||||
// Should be decodable
|
||||
const decoded = decodeTCString(tcString);
|
||||
expect(decoded.purposeConsents).toEqual(new Set([1, 2, 3]));
|
||||
});
|
||||
|
||||
it('updates getTcString return value', () => {
|
||||
installTcfApi(42, 1);
|
||||
expect(getTcString()).toBe('');
|
||||
|
||||
const model = createTCModel({ created: 100000, lastUpdated: 100000 });
|
||||
const tcString = updateTcfConsent(model);
|
||||
expect(getTcString()).toBe(tcString);
|
||||
});
|
||||
|
||||
it('works without installTcfApi (returns TC string, no listeners)', () => {
|
||||
// apiState is null, but should still encode
|
||||
const model = createTCModel({ created: 100000, lastUpdated: 100000 });
|
||||
const tcString = updateTcfConsent(model);
|
||||
expect(typeof tcString).toBe('string');
|
||||
expect(tcString.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── setTcfDisplayStatus ──────────────────────────────────────────────
|
||||
|
||||
describe('setTcfDisplayStatus', () => {
|
||||
beforeEach(() => {
|
||||
removeTcfApi();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
removeTcfApi();
|
||||
});
|
||||
|
||||
it('updates the display status returned by ping', () => {
|
||||
installTcfApi(42, 1);
|
||||
setTcfDisplayStatus('visible');
|
||||
|
||||
const callback = vi.fn();
|
||||
const api = window.__tcfapi as Function;
|
||||
api('ping', 2, callback);
|
||||
|
||||
expect(callback.mock.calls[0][0].displayStatus).toBe('visible');
|
||||
});
|
||||
|
||||
it('does nothing when API not installed', () => {
|
||||
// Should not throw
|
||||
expect(() => setTcfDisplayStatus('visible')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── removeTcfApi ─────────────────────────────────────────────────────
|
||||
|
||||
describe('removeTcfApi', () => {
|
||||
it('removes __tcfapi from window', () => {
|
||||
installTcfApi(42, 1);
|
||||
expect(window.__tcfapi).toBeDefined();
|
||||
|
||||
removeTcfApi();
|
||||
expect(window.__tcfapi).toBeUndefined();
|
||||
});
|
||||
|
||||
it('cleans up __tcfapiQueue', () => {
|
||||
window.__tcfapiQueue = [];
|
||||
installTcfApi(42, 1);
|
||||
|
||||
removeTcfApi();
|
||||
expect(window.__tcfapiQueue).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is safe to call multiple times', () => {
|
||||
expect(() => {
|
||||
removeTcfApi();
|
||||
removeTcfApi();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
119
apps/banner/src/a11y.ts
Normal file
119
apps/banner/src/a11y.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Accessibility utilities for the consent banner.
|
||||
*
|
||||
* Provides focus trapping, keyboard navigation, and screen reader
|
||||
* announcements for WCAG 2.1 AA compliance.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Trap focus within a container element.
|
||||
* Returns a cleanup function to remove the event listener.
|
||||
*/
|
||||
export function trapFocus(container: HTMLElement): () => void {
|
||||
function handleKeydown(e: KeyboardEvent): void {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const focusable = getFocusableElements(container);
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift+Tab: wrap from first to last
|
||||
if (document.activeElement === first || container.shadowRoot?.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
// Tab: wrap from last to first
|
||||
if (document.activeElement === last || container.shadowRoot?.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('keydown', handleKeydown);
|
||||
return () => container.removeEventListener('keydown', handleKeydown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Escape key to dismiss the banner.
|
||||
* Returns a cleanup function.
|
||||
*/
|
||||
export function onEscape(
|
||||
container: HTMLElement,
|
||||
callback: () => void,
|
||||
): () => void {
|
||||
function handleKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('keydown', handleKeydown);
|
||||
return () => container.removeEventListener('keydown', handleKeydown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all focusable elements within a container, including shadow DOM.
|
||||
*/
|
||||
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||
const selector = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(', ');
|
||||
|
||||
// Check shadow root first
|
||||
const root = container.shadowRoot ?? container;
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(selector));
|
||||
}
|
||||
|
||||
/**
|
||||
* Move focus to the first focusable element in the banner.
|
||||
*/
|
||||
export function focusFirst(container: HTMLElement): void {
|
||||
const elements = getFocusableElements(container);
|
||||
if (elements.length > 0) {
|
||||
elements[0].focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a visually hidden live region for screen reader announcements.
|
||||
* Returns the element so you can update its textContent.
|
||||
*/
|
||||
export function createLiveRegion(root: HTMLElement | ShadowRoot): HTMLElement {
|
||||
const region = document.createElement('div');
|
||||
region.setAttribute('role', 'status');
|
||||
region.setAttribute('aria-live', 'polite');
|
||||
region.setAttribute('aria-atomic', 'true');
|
||||
region.className = 'cmp-sr-only';
|
||||
root.appendChild(region);
|
||||
return region;
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce a message to screen readers via a live region.
|
||||
*/
|
||||
export function announce(liveRegion: HTMLElement, message: string): void {
|
||||
// Clear then set to ensure the screen reader picks up the change
|
||||
liveRegion.textContent = '';
|
||||
requestAnimationFrame(() => {
|
||||
liveRegion.textContent = message;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user prefers reduced motion.
|
||||
*/
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
836
apps/banner/src/banner.ts
Normal file
836
apps/banner/src/banner.ts
Normal file
@@ -0,0 +1,836 @@
|
||||
/**
|
||||
* consent-bundle.js — Full consent banner UI with Shadow DOM isolation.
|
||||
*
|
||||
* Loaded async by consent-loader.js when no existing consent is found.
|
||||
* Fetches site config, renders the banner, handles user interaction,
|
||||
* records consent via the API.
|
||||
*
|
||||
* Enterprise features (A/B testing, GPP, GPC, profile sync, Shopify,
|
||||
* re-consent) are loaded via the EE banner extension module when present.
|
||||
*/
|
||||
|
||||
import { announce, createLiveRegion, focusFirst, onEscape, prefersReducedMotion, trapFocus } from './a11y';
|
||||
import { updateAcceptedCategories } from './blocker';
|
||||
import { buildConsentState, readConsent, writeConsent } from './consent';
|
||||
import { buildGcmStateFromCategories, updateGcm } from './gcm';
|
||||
import { type TranslationStrings, DEFAULT_TRANSLATIONS, detectLocale, interpolate, loadTranslations, renderLinks } from './i18n';
|
||||
import type { BannerConfig, ButtonConfig, CategorySlug, SiteConfig } from './types';
|
||||
|
||||
// -- Preference-centre closure captured during init() ---------------------
|
||||
|
||||
/**
|
||||
* Holds a closure that re-opens the banner for consent withdrawal.
|
||||
* Populated during ``init()`` once config and translations are loaded,
|
||||
* and invoked by ``window.ConsentOS.showPreferences()``. The floating
|
||||
* "manage cookies" button also calls through this indirection so a
|
||||
* single entry point keeps the behaviour consistent.
|
||||
*/
|
||||
let _openPreferences: (() => void) | null = null;
|
||||
|
||||
// -- EE extension hooks (no-ops in CE mode) ---------------------------------
|
||||
|
||||
/** Result from the A/B test assignment. */
|
||||
interface ABAssignment {
|
||||
abTestId: string;
|
||||
variantId: string;
|
||||
variant: { name: string };
|
||||
}
|
||||
|
||||
/** Result from GPC evaluation. */
|
||||
interface GpcResult {
|
||||
detected: boolean;
|
||||
honoured: boolean;
|
||||
}
|
||||
|
||||
/** EE hooks that enterprise code can override at runtime. */
|
||||
interface EEHooks {
|
||||
applyABTest: (config: SiteConfig, visitorId: string) => { config: SiteConfig; assignment: ABAssignment | null };
|
||||
needsReconsent: (consent: unknown, config: SiteConfig) => { required: boolean; reasons: string[] };
|
||||
evaluateGpc: (config: SiteConfig, region: string | null) => GpcResult;
|
||||
getVisitorRegion: () => string | null;
|
||||
installGppApi: (cmpId: number, supportedApis: string[]) => void;
|
||||
setGppDisplayStatus: (status: string) => void;
|
||||
isGppApiInstalled: () => boolean;
|
||||
updateGppConsent: (gpp: unknown) => string | undefined;
|
||||
buildGppFromConsent: ((accepted: CategorySlug[], config: SiteConfig) => unknown) | null;
|
||||
identifyUser: (jwt: string, config: SiteConfig) => Promise<string[]>;
|
||||
clearIdentity: () => void;
|
||||
isIdentified: () => boolean;
|
||||
pushConsentToServer: (accepted: CategorySlug[], rejected: CategorySlug[], tc?: string, gpp?: string, gcm?: Record<string, string>) => void;
|
||||
updateShopifyConsent: (accepted: CategorySlug[]) => void;
|
||||
}
|
||||
|
||||
/** Default no-op hooks for CE mode. */
|
||||
const _hooks: EEHooks = {
|
||||
applyABTest: (config) => ({ config, assignment: null }),
|
||||
needsReconsent: () => ({ required: false, reasons: [] }),
|
||||
evaluateGpc: () => ({ detected: false, honoured: false }),
|
||||
getVisitorRegion: () => null,
|
||||
installGppApi: () => {},
|
||||
setGppDisplayStatus: () => {},
|
||||
isGppApiInstalled: () => false,
|
||||
updateGppConsent: () => undefined,
|
||||
buildGppFromConsent: null,
|
||||
identifyUser: async () => [],
|
||||
clearIdentity: () => {},
|
||||
isIdentified: () => false,
|
||||
pushConsentToServer: () => {},
|
||||
updateShopifyConsent: () => {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Register EE hooks. Called by the EE banner extension module.
|
||||
* Exposed on `window.__consentos_hooks` for the EE bundle to call.
|
||||
*/
|
||||
export function registerEEHooks(hooks: Partial<EEHooks>): void {
|
||||
Object.assign(_hooks, hooks);
|
||||
}
|
||||
|
||||
// Expose for EE bundle
|
||||
(window as any).__consentos_register_ee = registerEEHooks;
|
||||
|
||||
const ALL_CATEGORIES: CategorySlug[] = [
|
||||
'necessary',
|
||||
'functional',
|
||||
'analytics',
|
||||
'marketing',
|
||||
'personalisation',
|
||||
];
|
||||
|
||||
const NON_ESSENTIAL: CategorySlug[] = [
|
||||
'functional',
|
||||
'analytics',
|
||||
'marketing',
|
||||
'personalisation',
|
||||
];
|
||||
|
||||
/** Initialise the banner. Called when the bundle loads. */
|
||||
async function init(): Promise<void> {
|
||||
const { siteId, apiBase, cdnBase } = window.__consentos;
|
||||
if (!siteId) {
|
||||
console.warn('[ConsentOS] No site ID configured');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch site config — declared with let as A/B testing may replace it
|
||||
let config: SiteConfig;
|
||||
try {
|
||||
const resp = await fetch(`${apiBase}/api/v1/config/sites/${siteId}`);
|
||||
if (!resp.ok) throw new Error(`Config fetch failed: ${resp.status}`);
|
||||
config = await resp.json();
|
||||
} catch (err) {
|
||||
console.error('[ConsentOS] Failed to load site config:', err);
|
||||
config = buildDefaultConfig(siteId);
|
||||
}
|
||||
|
||||
// Apply A/B test variant assignment (modifies banner_config if applicable)
|
||||
const existingConsent = readConsent();
|
||||
const visitorId = existingConsent?.visitorId ?? crypto.randomUUID?.() ?? String(Date.now());
|
||||
const abResult = _hooks.applyABTest(config, visitorId);
|
||||
config = abResult.config;
|
||||
const abAssignment = abResult.assignment;
|
||||
|
||||
if (abAssignment) {
|
||||
console.info(`[ConsentOS] A/B test assigned: variant "${abAssignment.variant.name}"`);
|
||||
}
|
||||
|
||||
// Install the real CMP public API now that we have the config
|
||||
installCmpApi(config);
|
||||
|
||||
// Check if existing consent needs re-consent. We still load
|
||||
// translations and install the floating button even when no banner
|
||||
// needs to show, so the visitor can re-open the preference centre
|
||||
// at any time (GDPR Art. 7(3) — withdrawal must be as easy as
|
||||
// giving consent).
|
||||
let reconsentRequired = false;
|
||||
if (existingConsent) {
|
||||
const reconsent = _hooks.needsReconsent(existingConsent, config);
|
||||
reconsentRequired = reconsent.required;
|
||||
if (reconsent.required) {
|
||||
console.info('[ConsentOS] Re-consent required:', reconsent.reasons.join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
// Install GPP API if enabled
|
||||
if (config.gpp_enabled) {
|
||||
_hooks.installGppApi(0, config.gpp_supported_apis ?? []);
|
||||
_hooks.setGppDisplayStatus('visible');
|
||||
}
|
||||
|
||||
// Evaluate GPC signal
|
||||
const visitorRegion = _hooks.getVisitorRegion();
|
||||
const gpcResult = _hooks.evaluateGpc(config, visitorRegion);
|
||||
|
||||
if (gpcResult.detected) {
|
||||
console.info(`[ConsentOS] GPC signal detected (honoured: ${gpcResult.honoured})`);
|
||||
}
|
||||
|
||||
// Load translations
|
||||
const locale = detectLocale();
|
||||
const t = await loadTranslations(cdnBase, locale);
|
||||
|
||||
// Capture a closure that re-opens the banner with current consent
|
||||
// pre-filled. Called from the floating button and from
|
||||
// ``window.ConsentOS.showPreferences()``.
|
||||
_openPreferences = () => {
|
||||
removePreferencesButton();
|
||||
const current = readConsent();
|
||||
renderBanner(config, t, gpcResult, abAssignment, {
|
||||
prefillCategories: current?.accepted ?? null,
|
||||
showCategoriesInitially: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (existingConsent && !reconsentRequired) {
|
||||
// Banner isn't shown; expose the floating button straight away.
|
||||
showPreferencesButton(config, t);
|
||||
return;
|
||||
}
|
||||
|
||||
// First-visit or re-consent: render the banner itself.
|
||||
renderBanner(config, t, gpcResult, abAssignment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the real window.ConsentOS API, replacing the loader stubs.
|
||||
*
|
||||
* `identifyUser(jwt)` syncs consent with the server. If the server profile
|
||||
* fully covers all categories, the banner is suppressed. If categories are
|
||||
* missing, only those categories need consent from the user.
|
||||
*/
|
||||
function installCmpApi(config: SiteConfig): void {
|
||||
window.ConsentOS = {
|
||||
identifyUser: async (jwt: string): Promise<string[]> => {
|
||||
const missing = await _hooks.identifyUser(jwt, config);
|
||||
return missing;
|
||||
},
|
||||
clearIdentity: (): void => {
|
||||
_hooks.clearIdentity();
|
||||
},
|
||||
showPreferences: (): void => {
|
||||
if (_openPreferences) {
|
||||
_openPreferences();
|
||||
} else {
|
||||
console.warn('[ConsentOS] showPreferences called before init complete');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a default config when the API is unreachable. */
|
||||
function buildDefaultConfig(siteId: string): SiteConfig {
|
||||
return {
|
||||
id: '',
|
||||
site_id: siteId,
|
||||
blocking_mode: 'opt_in',
|
||||
regional_modes: null,
|
||||
tcf_enabled: false,
|
||||
gpp_enabled: false,
|
||||
gpp_supported_apis: [],
|
||||
gpc_enabled: true,
|
||||
gpc_jurisdictions: [],
|
||||
gpc_global_honour: false,
|
||||
gcm_enabled: true,
|
||||
gcm_default: null,
|
||||
shopify_privacy_enabled: false,
|
||||
banner_config: null,
|
||||
privacy_policy_url: null,
|
||||
terms_url: null,
|
||||
consent_expiry_days: 365,
|
||||
consent_group_id: null,
|
||||
ab_test: null,
|
||||
initiator_map: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Options for re-opening the banner from the preferences button. */
|
||||
interface OpenOptions {
|
||||
/** Pre-check these category slugs (skips strict-necessary which is always on). */
|
||||
prefillCategories: CategorySlug[] | null;
|
||||
/** Open the banner with the category toggles visible. */
|
||||
showCategoriesInitially: boolean;
|
||||
}
|
||||
|
||||
/** Create a Shadow DOM host and render the banner inside it. */
|
||||
function renderBanner(
|
||||
config: SiteConfig,
|
||||
t: TranslationStrings,
|
||||
gpcResult?: GpcResult,
|
||||
abAssignment?: ABAssignment | null,
|
||||
openOptions?: OpenOptions,
|
||||
): void {
|
||||
const host = document.createElement('div');
|
||||
host.id = 'consentos-banner-host';
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
|
||||
const titleId = 'cmp-title';
|
||||
const descId = 'cmp-desc';
|
||||
|
||||
shadow.innerHTML = `
|
||||
<style>${getBannerStyles(config)}</style>
|
||||
<div class="consentos-banner" role="dialog" aria-label="${t.title}" aria-labelledby="${titleId}" aria-describedby="${descId}" aria-modal="true">
|
||||
<div class="consentos-banner__content">
|
||||
<div class="consentos-banner__text">
|
||||
<p class="consentos-banner__title" id="${titleId}">${t.title}</p>
|
||||
<p class="consentos-banner__description" id="${descId}">
|
||||
${renderDescription(t.description, config)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="consentos-banner__categories" id="consentos-categories" role="group" aria-label="${t.managePreferences}">
|
||||
${renderCategories(t)}
|
||||
</div>
|
||||
<div class="consentos-banner__actions" role="group" aria-label="Consent actions">
|
||||
<button class="cmp-btn cmp-btn--secondary" data-action="reject" type="button">
|
||||
${t.rejectAll}
|
||||
</button>
|
||||
<button class="cmp-btn cmp-btn--secondary" data-action="settings" type="button" aria-expanded="false" aria-controls="consentos-categories">
|
||||
${t.managePreferences}
|
||||
</button>
|
||||
<button class="cmp-btn cmp-btn--primary" data-action="accept" type="button">
|
||||
${t.acceptAll}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Attach event listeners
|
||||
const banner = shadow.querySelector('.consentos-banner') as HTMLElement;
|
||||
const categoriesDiv = shadow.querySelector('#consentos-categories') as HTMLElement;
|
||||
const settingsBtn = shadow.querySelector('[data-action="settings"]') as HTMLElement;
|
||||
|
||||
// Hide or show the category toggles depending on entry mode.
|
||||
// Opening via ``showPreferences`` lands directly on the toggles.
|
||||
const startWithCategories = openOptions?.showCategoriesInitially === true;
|
||||
categoriesDiv.style.display = startWithCategories ? 'block' : 'none';
|
||||
settingsBtn.setAttribute('aria-expanded', startWithCategories ? 'true' : 'false');
|
||||
|
||||
// Pre-fill category checkboxes from existing consent when re-opened.
|
||||
if (openOptions?.prefillCategories) {
|
||||
const prefill = new Set(openOptions.prefillCategories);
|
||||
shadow.querySelectorAll<HTMLInputElement>('input[data-category]').forEach((input) => {
|
||||
const slug = input.getAttribute('data-category') as CategorySlug;
|
||||
if (slug === 'necessary') return; // always on + disabled
|
||||
input.checked = prefill.has(slug);
|
||||
});
|
||||
}
|
||||
|
||||
// Create live region for screen reader announcements
|
||||
const liveRegion = createLiveRegion(shadow);
|
||||
|
||||
// Set up keyboard navigation
|
||||
const cleanupFocusTrap = trapFocus(banner);
|
||||
const cleanupEscape = onEscape(banner, () => {
|
||||
handleConsent(['necessary'], NON_ESSENTIAL, config, gpcResult, abAssignment, t);
|
||||
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
||||
});
|
||||
|
||||
shadow.querySelectorAll('[data-action]').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const action = (e.currentTarget as HTMLElement).getAttribute('data-action');
|
||||
if (action === 'accept') {
|
||||
// Explicit Accept All overrides GPC — user choice takes precedence
|
||||
handleConsent(ALL_CATEGORIES, [], config, gpcResult, abAssignment, t);
|
||||
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
||||
} else if (action === 'reject') {
|
||||
handleConsent(['necessary'], NON_ESSENTIAL, config, gpcResult, abAssignment, t);
|
||||
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
||||
} else if (action === 'settings') {
|
||||
const isHidden = categoriesDiv.style.display === 'none';
|
||||
categoriesDiv.style.display = isHidden ? 'block' : 'none';
|
||||
settingsBtn.setAttribute('aria-expanded', isHidden ? 'true' : 'false');
|
||||
announce(liveRegion, isHidden ? t.managePreferences : t.title);
|
||||
} else if (action === 'save') {
|
||||
const accepted = getSelectedCategories(shadow);
|
||||
const rejected = NON_ESSENTIAL.filter((c) => !accepted.includes(c));
|
||||
handleConsent(accepted, rejected, config, gpcResult, abAssignment, t);
|
||||
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.body.appendChild(host);
|
||||
|
||||
// Move focus into the banner for keyboard users
|
||||
focusFirst(banner);
|
||||
}
|
||||
|
||||
/** Render category toggles HTML. */
|
||||
function renderCategories(t: TranslationStrings): string {
|
||||
const categories = [
|
||||
{ slug: 'necessary', name: t.categoryNecessary, desc: t.categoryNecessaryDesc, locked: true },
|
||||
{ slug: 'functional', name: t.categoryFunctional, desc: t.categoryFunctionalDesc, locked: false },
|
||||
{ slug: 'analytics', name: t.categoryAnalytics, desc: t.categoryAnalyticsDesc, locked: false },
|
||||
{ slug: 'marketing', name: t.categoryMarketing, desc: t.categoryMarketingDesc, locked: false },
|
||||
{ slug: 'personalisation', name: t.categoryPersonalisation, desc: t.categoryPersonalisationDesc, locked: false },
|
||||
];
|
||||
|
||||
return (
|
||||
categories
|
||||
.map(
|
||||
(cat) => `
|
||||
<label class="cmp-category">
|
||||
<div class="cmp-category__info">
|
||||
<span class="cmp-category__name" id="cmp-cat-${cat.slug}">${cat.name}</span>
|
||||
<span class="cmp-category__desc" id="cmp-cat-${cat.slug}-desc">${cat.desc}</span>
|
||||
</div>
|
||||
<input type="checkbox" data-category="${cat.slug}"
|
||||
aria-labelledby="cmp-cat-${cat.slug}"
|
||||
aria-describedby="cmp-cat-${cat.slug}-desc"
|
||||
${cat.locked ? 'checked disabled' : ''}
|
||||
/>
|
||||
</label>
|
||||
`
|
||||
)
|
||||
.join('') +
|
||||
`<button class="cmp-btn cmp-btn--primary cmp-btn--save" data-action="save" type="button">
|
||||
${t.savePreferences}
|
||||
</button>`
|
||||
);
|
||||
}
|
||||
|
||||
/** Read which categories are checked in the shadow DOM. */
|
||||
function getSelectedCategories(shadow: ShadowRoot): CategorySlug[] {
|
||||
const checked: CategorySlug[] = ['necessary'];
|
||||
shadow.querySelectorAll<HTMLInputElement>('input[data-category]').forEach((input) => {
|
||||
if (input.checked) {
|
||||
checked.push(input.getAttribute('data-category') as CategorySlug);
|
||||
}
|
||||
});
|
||||
return [...new Set(checked)];
|
||||
}
|
||||
|
||||
/** Handle a consent decision: write cookie, update GCM, GPP, post to API, dispatch event. */
|
||||
function handleConsent(
|
||||
accepted: CategorySlug[],
|
||||
rejected: CategorySlug[],
|
||||
config: SiteConfig,
|
||||
gpcResult?: GpcResult,
|
||||
abAssignment?: ABAssignment | null,
|
||||
t?: TranslationStrings,
|
||||
): void {
|
||||
const existing = readConsent();
|
||||
const gcmState = buildGcmStateFromCategories(accepted);
|
||||
|
||||
// Generate GPP string if GPP is enabled
|
||||
let gppString: string | undefined;
|
||||
if (config.gpp_enabled && _hooks.isGppApiInstalled() && _hooks.buildGppFromConsent) {
|
||||
const gpp = _hooks.buildGppFromConsent(accepted, config);
|
||||
gppString = _hooks.updateGppConsent(gpp);
|
||||
_hooks.setGppDisplayStatus('hidden');
|
||||
}
|
||||
|
||||
const state = buildConsentState(
|
||||
accepted,
|
||||
rejected,
|
||||
existing?.visitorId,
|
||||
undefined,
|
||||
gcmState,
|
||||
config.id,
|
||||
gppString,
|
||||
gpcResult?.detected,
|
||||
gpcResult?.honoured,
|
||||
);
|
||||
|
||||
// Write first-party cookie
|
||||
writeConsent(state, config.consent_expiry_days);
|
||||
|
||||
// Release blocked scripts for accepted categories
|
||||
updateAcceptedCategories(accepted);
|
||||
|
||||
// Update Google Consent Mode
|
||||
if (config.gcm_enabled) {
|
||||
updateGcm(gcmState);
|
||||
}
|
||||
|
||||
// Update Shopify Customer Privacy API
|
||||
if (config.shopify_privacy_enabled) {
|
||||
_hooks.updateShopifyConsent(accepted);
|
||||
}
|
||||
|
||||
// Post consent to API (fire and forget)
|
||||
const { siteId, apiBase } = window.__consentos;
|
||||
fetch(`${apiBase}/api/v1/consent/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
site_id: siteId,
|
||||
visitor_id: state.visitorId,
|
||||
action: determineAction(accepted, rejected),
|
||||
categories_accepted: accepted,
|
||||
categories_rejected: rejected,
|
||||
gcm_state: gcmState,
|
||||
gpc_detected: gpcResult?.detected ?? false,
|
||||
gpc_honoured: gpcResult?.honoured ?? false,
|
||||
page_url: window.location.href,
|
||||
ab_test_id: abAssignment?.abTestId ?? null,
|
||||
ab_variant_id: abAssignment?.variantId ?? null,
|
||||
}),
|
||||
}).catch((err) => console.warn('[ConsentOS] Failed to record consent:', err));
|
||||
|
||||
// Push to server if user is identified (non-blocking background sync)
|
||||
if (_hooks.isIdentified()) {
|
||||
_hooks.pushConsentToServer(accepted, rejected, undefined, gppString, gcmState);
|
||||
}
|
||||
|
||||
// Dispatch event
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('consentos:consent-change', { detail: { accepted } })
|
||||
);
|
||||
|
||||
if (typeof window.dataLayer !== 'undefined') {
|
||||
window.dataLayer.push({
|
||||
event: 'consentos_consent_change',
|
||||
cmp_accepted_categories: accepted,
|
||||
});
|
||||
}
|
||||
|
||||
// Bridge for the standalone ConsentOS GTM template — when the
|
||||
// template is loaded on the page it registers a global callback so
|
||||
// it can react to consent changes. Lives in its own repo.
|
||||
if (typeof (window as any).__consentos_gtm_consent_update === 'function') {
|
||||
(window as any).__consentos_gtm_consent_update({ accepted });
|
||||
}
|
||||
|
||||
// Re-expose the floating preferences button so the visitor can
|
||||
// withdraw or change their decision later.
|
||||
if (t) {
|
||||
showPreferencesButton(config, t);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the banner description with template variables and markdown links.
|
||||
*
|
||||
* Replaces `{{privacy_policy}}` and `{{terms}}` with their URLs from config,
|
||||
* then converts `[text](url)` markdown links to `<a>` tags.
|
||||
* Links with empty URLs (because the config field is unset) are removed.
|
||||
*/
|
||||
function renderDescription(description: string, config: SiteConfig): string {
|
||||
const rendered = interpolate(description, {
|
||||
privacy_policy: config.privacy_policy_url ?? '',
|
||||
terms: config.terms_url ?? '',
|
||||
});
|
||||
return renderLinks(rendered);
|
||||
}
|
||||
|
||||
/** Determine the consent action string. */
|
||||
function determineAction(
|
||||
accepted: CategorySlug[],
|
||||
rejected: CategorySlug[]
|
||||
): string {
|
||||
if (rejected.length === 0) return 'accept_all';
|
||||
if (accepted.length === 1 && accepted[0] === 'necessary') return 'reject_all';
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
/** Remove the banner from the DOM. */
|
||||
function removeBanner(
|
||||
host: HTMLElement,
|
||||
...cleanups: Array<() => void>
|
||||
): void {
|
||||
cleanups.forEach((fn) => fn());
|
||||
const useMotion = !prefersReducedMotion();
|
||||
if (useMotion) {
|
||||
host.style.opacity = '0';
|
||||
host.style.transition = 'opacity 0.3s ease';
|
||||
setTimeout(() => host.remove(), 300);
|
||||
} else {
|
||||
host.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// -- Floating "manage preferences" button ---------------------------------
|
||||
|
||||
const _PREFERENCES_BUTTON_ID = 'cmp-preferences-button';
|
||||
|
||||
/** Remove the floating preferences button if present. */
|
||||
function removePreferencesButton(): void {
|
||||
const existing = document.getElementById(_PREFERENCES_BUTTON_ID);
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a persistent floating button that re-opens the banner.
|
||||
*
|
||||
* Required by GDPR Art. 7(3) — withdrawing consent must be as easy
|
||||
* as giving it. Positioned opposite the banner's corner by default
|
||||
* so it doesn't sit behind the initial banner if displayed together.
|
||||
*/
|
||||
function showPreferencesButton(config: SiteConfig, t: TranslationStrings): void {
|
||||
removePreferencesButton();
|
||||
|
||||
// Honour the site's opt-out: operators can disable the floating
|
||||
// button via ``banner_config.show_preferences_button = false``.
|
||||
const bc = config.banner_config ?? null;
|
||||
if (bc && (bc as Record<string, unknown>).show_preferences_button === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position =
|
||||
(bc as Record<string, unknown> | null)?.preferences_button_position === 'left'
|
||||
? 'left: 20px;'
|
||||
: 'right: 20px;';
|
||||
|
||||
const host = document.createElement('div');
|
||||
host.id = _PREFERENCES_BUTTON_ID;
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
|
||||
const label =
|
||||
t.managePreferences || 'Cookie preferences';
|
||||
|
||||
shadow.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
${position}
|
||||
z-index: 2147483646;
|
||||
}
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 999px;
|
||||
font: 500 0.85rem system-ui, -apple-system, sans-serif;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
button:hover { opacity: 0.92; }
|
||||
button:focus-visible {
|
||||
outline: 2px solid #4a90e2;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
svg { width: 1rem; height: 1rem; flex-shrink: 0; }
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
button { transition: transform 0.15s ease; }
|
||||
button:hover { transform: translateY(-1px); }
|
||||
}
|
||||
</style>
|
||||
<button type="button" aria-label="${label}" title="${label}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M8.5 8.5v.01"/>
|
||||
<path d="M16 15.5v.01"/>
|
||||
<path d="M12 12v.01"/>
|
||||
<path d="M11 17v.01"/>
|
||||
<path d="M7 14v.01"/>
|
||||
</svg>
|
||||
<span>${label}</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
const btn = shadow.querySelector('button') as HTMLButtonElement;
|
||||
btn.addEventListener('click', () => {
|
||||
if (_openPreferences) {
|
||||
_openPreferences();
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(host);
|
||||
}
|
||||
|
||||
/** Resolve position CSS for the banner based on display mode. */
|
||||
function getPositionCss(bc: BannerConfig | null): string {
|
||||
const mode = bc?.displayMode ?? 'bottom_banner';
|
||||
const radius = bc?.borderRadius ?? 6;
|
||||
const cornerPos = bc?.cornerPosition ?? 'right';
|
||||
|
||||
switch (mode) {
|
||||
case 'top_banner':
|
||||
return 'position: fixed; top: 0; left: 0; right: 0; z-index: 2147483647;';
|
||||
case 'overlay':
|
||||
return `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2147483647; width: 90%; max-width: 600px; border-radius: ${radius}px;`;
|
||||
case 'corner_popup': {
|
||||
const side = cornerPos === 'left' ? 'left: 20px;' : 'right: 20px;';
|
||||
return `position: fixed; bottom: 20px; ${side} z-index: 2147483647; width: 380px; max-width: calc(100% - 40px); border-radius: ${radius}px;`;
|
||||
}
|
||||
case 'bottom_banner':
|
||||
default:
|
||||
return 'position: fixed; bottom: 0; left: 0; right: 0; z-index: 2147483647;';
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve per-button inline style from ButtonConfig. */
|
||||
function getButtonCss(
|
||||
btnCfg: ButtonConfig | undefined,
|
||||
fallbackBg: string,
|
||||
fallbackColor: string,
|
||||
fallbackBorder: string,
|
||||
radius: number,
|
||||
): string {
|
||||
const style = btnCfg?.style;
|
||||
const bg = style === 'text' || style === 'outline'
|
||||
? 'transparent'
|
||||
: btnCfg?.backgroundColour ?? fallbackBg;
|
||||
const color = btnCfg?.textColour ?? fallbackColor;
|
||||
const border = btnCfg?.borderColour
|
||||
? `1px solid ${btnCfg.borderColour}`
|
||||
: style === 'outline'
|
||||
? `1px solid ${color}`
|
||||
: style === 'text'
|
||||
? 'none'
|
||||
: fallbackBorder;
|
||||
|
||||
return `background: ${bg}; color: ${color}; border: ${border}; border-radius: ${radius}px;`;
|
||||
}
|
||||
|
||||
/** Banner CSS — isolated inside Shadow DOM. */
|
||||
function getBannerStyles(config: SiteConfig): string {
|
||||
const bc = config.banner_config;
|
||||
const bg = bc?.backgroundColour ?? '#ffffff';
|
||||
const text = bc?.textColour ?? '#0E1929'; // ConsentOS Ink
|
||||
const primary = bc?.primaryColour ?? '#2C6AE4'; // ConsentOS Action Blue
|
||||
const font = bc?.fontFamily ?? '-apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif';
|
||||
const radius = bc?.borderRadius ?? 6;
|
||||
const mode = bc?.displayMode ?? 'bottom_banner';
|
||||
|
||||
const acceptCss = getButtonCss(bc?.acceptButton, primary, '#ffffff', 'none', radius);
|
||||
const rejectCss = getButtonCss(bc?.rejectButton, 'transparent', text, '1px solid rgba(0,0,0,0.2)', radius);
|
||||
const manageCss = getButtonCss(bc?.manageButton, 'transparent', text, '1px solid rgba(0,0,0,0.2)', radius);
|
||||
|
||||
return `
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
.consentos-banner {
|
||||
${getPositionCss(bc)}
|
||||
background: ${bg};
|
||||
color: ${text};
|
||||
font-family: ${font}, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: ${mode === 'overlay' || mode === 'corner_popup' ? radius + 'px' : '0'};
|
||||
}
|
||||
|
||||
.consentos-banner__content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.consentos-banner__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.consentos-banner__description {
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.consentos-banner__link {
|
||||
color: ${primary};
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.consentos-banner__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cmp-btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: ${radius}px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: opacity 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.cmp-btn:hover { opacity: 0.9; }
|
||||
.cmp-btn:focus-visible {
|
||||
outline: 2px solid ${primary};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cmp-btn--primary { ${acceptCss} }
|
||||
.cmp-btn--secondary[data-action="reject"] { ${rejectCss} }
|
||||
.cmp-btn--secondary[data-action="settings"] { ${manageCss} }
|
||||
.cmp-btn--secondary { ${rejectCss} }
|
||||
|
||||
.consentos-banner__categories {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cmp-category {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cmp-category__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.cmp-category__name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cmp-category__desc {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cmp-category input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: ${primary};
|
||||
}
|
||||
|
||||
.cmp-btn--save {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Visually hidden but accessible to screen readers */
|
||||
.cmp-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.consentos-banner__actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
.cmp-btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
transition-duration: 0s !important;
|
||||
animation-duration: 0s !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// Auto-init on load
|
||||
init();
|
||||
522
apps/banner/src/blocker.ts
Normal file
522
apps/banner/src/blocker.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* blocker.ts — Script interceptor, cookie blocker, and release manager.
|
||||
*
|
||||
* Installs before any third-party scripts run. Intercepts script creation,
|
||||
* proxies document.cookie and Storage writes, and maintains a queue of
|
||||
* blocked resources that are released per-category when consent is granted.
|
||||
*/
|
||||
|
||||
import type { CategorySlug, InitiatorMapping } from './types';
|
||||
|
||||
/** A script element that was blocked, along with its assigned category. */
|
||||
interface BlockedScript {
|
||||
/** The original script element or a clone of it. */
|
||||
element: HTMLScriptElement;
|
||||
/** The consent category this script belongs to. */
|
||||
category: CategorySlug;
|
||||
}
|
||||
|
||||
/** Pattern-to-category mapping for URL-based script classification. */
|
||||
interface ScriptPattern {
|
||||
pattern: RegExp;
|
||||
category: CategorySlug;
|
||||
}
|
||||
|
||||
/** Categories that have been consented to. */
|
||||
let acceptedCategories: Set<CategorySlug> = new Set(['necessary']);
|
||||
|
||||
/** Queue of blocked scripts awaiting consent. */
|
||||
const blockedScripts: BlockedScript[] = [];
|
||||
|
||||
/** URL patterns for classifying scripts by category. */
|
||||
const scriptPatterns: ScriptPattern[] = [];
|
||||
|
||||
/** Root initiator URL → category mappings for root-level blocking. */
|
||||
const initiatorMappings: Array<{ pattern: RegExp; category: CategorySlug }> = [];
|
||||
|
||||
/** Whether the blocker has been installed. */
|
||||
let installed = false;
|
||||
|
||||
/** Original document.createElement reference. */
|
||||
let originalCreateElement: typeof document.createElement;
|
||||
|
||||
/** Original document.cookie descriptor. */
|
||||
let originalCookieDescriptor: PropertyDescriptor | undefined;
|
||||
|
||||
/** Original Storage.prototype.setItem reference. */
|
||||
let originalLocalStorageSetItem: typeof Storage.prototype.setItem;
|
||||
|
||||
// ─── Well-known script patterns (built-in defaults) ───
|
||||
|
||||
const BUILTIN_PATTERNS: ScriptPattern[] = [
|
||||
// Analytics
|
||||
{ pattern: /google-analytics\.com/i, category: 'analytics' },
|
||||
{ pattern: /googletagmanager\.com/i, category: 'analytics' },
|
||||
{ pattern: /gtag\/js/i, category: 'analytics' },
|
||||
{ pattern: /analytics\./i, category: 'analytics' },
|
||||
{ pattern: /hotjar\.com/i, category: 'analytics' },
|
||||
{ pattern: /clarity\.ms/i, category: 'analytics' },
|
||||
{ pattern: /plausible\.io/i, category: 'analytics' },
|
||||
{ pattern: /matomo\./i, category: 'analytics' },
|
||||
|
||||
// Marketing
|
||||
{ pattern: /doubleclick\.net/i, category: 'marketing' },
|
||||
{ pattern: /facebook\.net/i, category: 'marketing' },
|
||||
{ pattern: /fbevents\.js/i, category: 'marketing' },
|
||||
{ pattern: /connect\.facebook/i, category: 'marketing' },
|
||||
{ pattern: /ads-twitter\.com/i, category: 'marketing' },
|
||||
{ pattern: /linkedin\.com\/insight/i, category: 'marketing' },
|
||||
{ pattern: /snap\.licdn\.com/i, category: 'marketing' },
|
||||
{ pattern: /tiktok\.com\/i18n/i, category: 'marketing' },
|
||||
{ pattern: /googlesyndication\.com/i, category: 'marketing' },
|
||||
{ pattern: /adservice\.google/i, category: 'marketing' },
|
||||
|
||||
// Functional
|
||||
{ pattern: /intercom\.com/i, category: 'functional' },
|
||||
{ pattern: /crisp\.chat/i, category: 'functional' },
|
||||
{ pattern: /livechatinc\.com/i, category: 'functional' },
|
||||
{ pattern: /zendesk\.com/i, category: 'functional' },
|
||||
];
|
||||
|
||||
// ─── Public API ───
|
||||
|
||||
/** Install all interception hooks. Call once, as early as possible. */
|
||||
export function installBlocker(): void {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
|
||||
// Merge built-in patterns
|
||||
scriptPatterns.push(...BUILTIN_PATTERNS);
|
||||
|
||||
// Install hooks
|
||||
installCreateElementOverride();
|
||||
installMutationObserver();
|
||||
installCookieProxy();
|
||||
installStorageProxy();
|
||||
}
|
||||
|
||||
/** Add custom URL-to-category patterns (e.g. from site config allow-list). */
|
||||
export function addScriptPatterns(patterns: Array<{ pattern: string; category: CategorySlug }>): void {
|
||||
for (const p of patterns) {
|
||||
try {
|
||||
scriptPatterns.push({ pattern: new RegExp(p.pattern, 'i'), category: p.category });
|
||||
} catch {
|
||||
console.warn(`[ConsentOS] Invalid script pattern: ${p.pattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load initiator mappings from the site config. Each mapping identifies a root
|
||||
* script URL that is known to set cookies in a given category via a chain of
|
||||
* child scripts. Blocking the root prevents the entire chain from executing.
|
||||
*/
|
||||
export function loadInitiatorMappings(mappings: InitiatorMapping[]): void {
|
||||
for (const m of mappings) {
|
||||
try {
|
||||
initiatorMappings.push({ pattern: new RegExp(m.root_script, 'i'), category: m.category });
|
||||
} catch {
|
||||
console.warn(`[ConsentOS] Invalid initiator pattern: ${m.root_script}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the set of accepted categories and release any blocked scripts
|
||||
* that now have consent.
|
||||
*/
|
||||
export function updateAcceptedCategories(categories: CategorySlug[]): void {
|
||||
acceptedCategories = new Set(categories);
|
||||
releaseBlockedScripts();
|
||||
}
|
||||
|
||||
/** Get the current blocked script count (useful for debugging/reporting). */
|
||||
export function getBlockedCount(): number {
|
||||
return blockedScripts.length;
|
||||
}
|
||||
|
||||
/** Check whether a given category is currently accepted. */
|
||||
export function isCategoryAllowed(category: CategorySlug): boolean {
|
||||
return acceptedCategories.has(category);
|
||||
}
|
||||
|
||||
// ─── Script interception ───
|
||||
|
||||
/**
|
||||
* Override document.createElement to intercept <script> creation.
|
||||
* When a script is created and its src matches a known pattern,
|
||||
* we set its type to 'text/blocked' to prevent execution.
|
||||
*/
|
||||
function installCreateElementOverride(): void {
|
||||
originalCreateElement = document.createElement.bind(document);
|
||||
|
||||
document.createElement = function (
|
||||
tagName: string,
|
||||
options?: ElementCreationOptions
|
||||
): HTMLElement {
|
||||
const element = originalCreateElement(tagName, options);
|
||||
|
||||
if (tagName.toLowerCase() === 'script') {
|
||||
const script = element as HTMLScriptElement;
|
||||
wrapScriptElement(script);
|
||||
}
|
||||
|
||||
return element;
|
||||
} as typeof document.createElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a script element's `src` setter so that when a src is assigned,
|
||||
* we can classify and potentially block it.
|
||||
*/
|
||||
function wrapScriptElement(script: HTMLScriptElement): void {
|
||||
const originalSrcDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLScriptElement.prototype,
|
||||
'src'
|
||||
);
|
||||
if (!originalSrcDescriptor) return;
|
||||
|
||||
let pendingSrc = '';
|
||||
|
||||
Object.defineProperty(script, 'src', {
|
||||
get() {
|
||||
return pendingSrc || originalSrcDescriptor.get?.call(this) || '';
|
||||
},
|
||||
set(value: string) {
|
||||
pendingSrc = value;
|
||||
const category = classifyScript(value, script);
|
||||
|
||||
if (category && category !== 'necessary' && !acceptedCategories.has(category)) {
|
||||
// Block: change type to prevent execution
|
||||
script.type = 'text/blocked';
|
||||
script.setAttribute('data-consentos-blocked', 'true');
|
||||
script.setAttribute('data-consentos-category', category);
|
||||
script.setAttribute('data-consentos-original-src', value);
|
||||
}
|
||||
|
||||
originalSrcDescriptor.set?.call(this, value);
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* MutationObserver watches for script elements being added to the DOM.
|
||||
* If a script should be blocked, we remove it and queue it.
|
||||
*/
|
||||
function installMutationObserver(): void {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node instanceof HTMLScriptElement) {
|
||||
handleInsertedScript(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Observe as early as possible
|
||||
if (document.documentElement) {
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle a script element that was just inserted into the DOM. */
|
||||
function handleInsertedScript(script: HTMLScriptElement): void {
|
||||
// Skip if it's our own script or already processed
|
||||
if (script.hasAttribute('data-consentos-allowed') || script.hasAttribute('data-consentos-queued')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check explicit data-category attribute first
|
||||
const explicitCategory = script.getAttribute('data-category') as CategorySlug | null;
|
||||
const src = script.getAttribute('data-consentos-original-src') || script.src || '';
|
||||
const category = explicitCategory || classifyScript(src, script);
|
||||
|
||||
// Necessary scripts always pass through
|
||||
if (!category || category === 'necessary') {
|
||||
return;
|
||||
}
|
||||
|
||||
// If already consented, allow through
|
||||
if (acceptedCategories.has(category)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Block: remove from DOM and queue
|
||||
script.setAttribute('data-consentos-queued', 'true');
|
||||
|
||||
// Clone the script for later re-insertion
|
||||
const clone = originalCreateElement('script') as HTMLScriptElement;
|
||||
// Copy attributes
|
||||
for (const attr of Array.from(script.attributes)) {
|
||||
if (attr.name !== 'type' && attr.name !== 'data-consentos-blocked' && attr.name !== 'data-consentos-queued') {
|
||||
clone.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
}
|
||||
// Copy inline content
|
||||
if (script.textContent) {
|
||||
clone.textContent = script.textContent;
|
||||
}
|
||||
// Restore original src if it was rewritten
|
||||
const originalSrc = script.getAttribute('data-consentos-original-src');
|
||||
if (originalSrc) {
|
||||
clone.setAttribute('data-consentos-original-src', originalSrc);
|
||||
}
|
||||
|
||||
blockedScripts.push({ element: clone, category });
|
||||
|
||||
// Remove from DOM to prevent execution
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cookie proxy ───
|
||||
|
||||
/**
|
||||
* Proxy document.cookie setter to block cookie writes from
|
||||
* non-essential categories. We check the cookie name against
|
||||
* known patterns and the ConsentOS's own cookie is always allowed.
|
||||
*/
|
||||
function installCookieProxy(): void {
|
||||
originalCookieDescriptor = Object.getOwnPropertyDescriptor(
|
||||
Document.prototype,
|
||||
'cookie'
|
||||
);
|
||||
if (!originalCookieDescriptor) return;
|
||||
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
get() {
|
||||
return originalCookieDescriptor!.get?.call(document) ?? '';
|
||||
},
|
||||
set(value: string) {
|
||||
// Always allow ConsentOS's own cookies
|
||||
if (value.startsWith('_consentos_')) {
|
||||
originalCookieDescriptor!.set?.call(document, value);
|
||||
return;
|
||||
}
|
||||
|
||||
// If consent hasn't been collected yet and we're in opt-in mode,
|
||||
// block all non-essential cookie writes
|
||||
if (!allNonEssentialConsented()) {
|
||||
const cookieName = parseCookieName(value);
|
||||
const category = classifyCookie(cookieName);
|
||||
|
||||
if (category && category !== 'necessary' && !acceptedCategories.has(category)) {
|
||||
// Silently block
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
originalCookieDescriptor!.set?.call(document, value);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Storage proxy ───
|
||||
|
||||
/** Proxy localStorage and sessionStorage setItem to block non-essential writes. */
|
||||
function installStorageProxy(): void {
|
||||
if (typeof Storage !== 'undefined') {
|
||||
originalLocalStorageSetItem = Storage.prototype.setItem;
|
||||
Storage.prototype.setItem = function (key: string, value: string): void {
|
||||
if (shouldBlockStorageWrite(key)) return;
|
||||
originalLocalStorageSetItem.call(this, key, value);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a storage write should be blocked. */
|
||||
function shouldBlockStorageWrite(key: string): boolean {
|
||||
// Always allow ConsentOS's own storage
|
||||
if (key.startsWith('_consentos_')) return false;
|
||||
|
||||
// If all non-essential categories are consented, allow everything
|
||||
if (allNonEssentialConsented()) return false;
|
||||
|
||||
// Block known tracking storage keys
|
||||
const category = classifyStorageKey(key);
|
||||
if (category && category !== 'necessary' && !acceptedCategories.has(category)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Release manager ───
|
||||
|
||||
/** Release blocked scripts whose categories are now accepted. */
|
||||
function releaseBlockedScripts(): void {
|
||||
const toRelease: BlockedScript[] = [];
|
||||
const remaining: BlockedScript[] = [];
|
||||
|
||||
for (const blocked of blockedScripts) {
|
||||
if (acceptedCategories.has(blocked.category)) {
|
||||
toRelease.push(blocked);
|
||||
} else {
|
||||
remaining.push(blocked);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear and repopulate the queue
|
||||
blockedScripts.length = 0;
|
||||
blockedScripts.push(...remaining);
|
||||
|
||||
// Re-insert released scripts in order
|
||||
for (const { element } of toRelease) {
|
||||
const script = originalCreateElement('script') as HTMLScriptElement;
|
||||
|
||||
// Copy all attributes
|
||||
for (const attr of Array.from(element.attributes)) {
|
||||
if (attr.name !== 'data-consentos-blocked' && attr.name !== 'data-consentos-queued' && attr.name !== 'data-consentos-category') {
|
||||
script.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Use original src if stored
|
||||
const originalSrc = element.getAttribute('data-consentos-original-src');
|
||||
if (originalSrc) {
|
||||
script.src = originalSrc;
|
||||
script.removeAttribute('data-consentos-original-src');
|
||||
}
|
||||
|
||||
// Copy inline script content
|
||||
if (element.textContent && !script.src) {
|
||||
script.textContent = element.textContent;
|
||||
}
|
||||
|
||||
// Mark as allowed so the observer doesn't re-block it
|
||||
script.setAttribute('data-consentos-allowed', 'true');
|
||||
|
||||
// Insert into head
|
||||
(document.head || document.documentElement).appendChild(script);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Classification helpers ───
|
||||
|
||||
/** Classify a script by its URL against known patterns and initiator mappings. */
|
||||
function classifyScript(src: string, script: HTMLScriptElement): CategorySlug | null {
|
||||
if (!src) return null;
|
||||
|
||||
// Explicit data-category always wins
|
||||
const explicit = script.getAttribute('data-category') as CategorySlug | null;
|
||||
if (explicit) return explicit;
|
||||
|
||||
// Match against URL patterns
|
||||
for (const { pattern, category } of scriptPatterns) {
|
||||
if (pattern.test(src)) return category;
|
||||
}
|
||||
|
||||
// Check initiator mappings — block root scripts that are known to set
|
||||
// cookies in non-consented categories via downstream child scripts
|
||||
for (const { pattern, category } of initiatorMappings) {
|
||||
if (pattern.test(src)) return category;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Well-known cookie name patterns mapped to categories. */
|
||||
const COOKIE_PATTERNS: Array<{ pattern: RegExp; category: CategorySlug }> = [
|
||||
// Analytics
|
||||
{ pattern: /^_ga/i, category: 'analytics' },
|
||||
{ pattern: /^_gid$/i, category: 'analytics' },
|
||||
{ pattern: /^_gat/i, category: 'analytics' },
|
||||
{ pattern: /^_hjSession/i, category: 'analytics' },
|
||||
{ pattern: /^_hj/i, category: 'analytics' },
|
||||
{ pattern: /^_pk_/i, category: 'analytics' },
|
||||
{ pattern: /^_clck$/i, category: 'analytics' },
|
||||
{ pattern: /^_clsk$/i, category: 'analytics' },
|
||||
|
||||
// Marketing
|
||||
{ pattern: /^_fbp$/i, category: 'marketing' },
|
||||
{ pattern: /^_fbc$/i, category: 'marketing' },
|
||||
{ pattern: /^_gcl_/i, category: 'marketing' },
|
||||
{ pattern: /^IDE$/i, category: 'marketing' },
|
||||
{ pattern: /^NID$/i, category: 'marketing' },
|
||||
{ pattern: /^test_cookie$/i, category: 'marketing' },
|
||||
{ pattern: /^_uetsid/i, category: 'marketing' },
|
||||
{ pattern: /^_uetvid/i, category: 'marketing' },
|
||||
|
||||
// Functional
|
||||
{ pattern: /^intercom-/i, category: 'functional' },
|
||||
{ pattern: /^crisp-/i, category: 'functional' },
|
||||
];
|
||||
|
||||
/** Classify a cookie by its name. */
|
||||
function classifyCookie(name: string): CategorySlug | null {
|
||||
for (const { pattern, category } of COOKIE_PATTERNS) {
|
||||
if (pattern.test(name)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Well-known storage key patterns. */
|
||||
const STORAGE_PATTERNS: Array<{ pattern: RegExp; category: CategorySlug }> = [
|
||||
{ pattern: /^_ga/i, category: 'analytics' },
|
||||
{ pattern: /^_hj/i, category: 'analytics' },
|
||||
{ pattern: /^intercom\./i, category: 'functional' },
|
||||
{ pattern: /^crisp-/i, category: 'functional' },
|
||||
{ pattern: /^fb_/i, category: 'marketing' },
|
||||
];
|
||||
|
||||
/** Classify a storage key by known patterns. */
|
||||
function classifyStorageKey(key: string): CategorySlug | null {
|
||||
for (const { pattern, category } of STORAGE_PATTERNS) {
|
||||
if (pattern.test(key)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse the cookie name from a Set-Cookie string. */
|
||||
function parseCookieName(cookieString: string): string {
|
||||
const eqIndex = cookieString.indexOf('=');
|
||||
if (eqIndex === -1) return cookieString.trim();
|
||||
return cookieString.substring(0, eqIndex).trim();
|
||||
}
|
||||
|
||||
/** Check if all non-essential categories have been consented to. */
|
||||
function allNonEssentialConsented(): boolean {
|
||||
return (
|
||||
acceptedCategories.has('functional') &&
|
||||
acceptedCategories.has('analytics') &&
|
||||
acceptedCategories.has('marketing') &&
|
||||
acceptedCategories.has('personalisation')
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Teardown (for testing) ───
|
||||
|
||||
/** Remove all interception hooks. Used in tests. */
|
||||
export function uninstallBlocker(): void {
|
||||
if (!installed) return;
|
||||
|
||||
// Restore document.createElement
|
||||
if (originalCreateElement) {
|
||||
document.createElement = originalCreateElement;
|
||||
}
|
||||
|
||||
// Restore document.cookie
|
||||
if (originalCookieDescriptor) {
|
||||
Object.defineProperty(document, 'cookie', originalCookieDescriptor);
|
||||
}
|
||||
|
||||
// Restore storage
|
||||
if (originalLocalStorageSetItem) {
|
||||
Storage.prototype.setItem = originalLocalStorageSetItem;
|
||||
}
|
||||
|
||||
// Clear state
|
||||
blockedScripts.length = 0;
|
||||
scriptPatterns.length = 0;
|
||||
initiatorMappings.length = 0;
|
||||
acceptedCategories = new Set(['necessary']);
|
||||
installed = false;
|
||||
}
|
||||
91
apps/banner/src/consent.ts
Normal file
91
apps/banner/src/consent.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { CategorySlug, ConsentState } from './types';
|
||||
|
||||
const COOKIE_NAME = '_consentos_consent';
|
||||
const BANNER_VERSION = '0.1.0';
|
||||
|
||||
/** Generate a simple visitor ID (UUID v4-like). */
|
||||
export function generateVisitorId(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback for older browsers
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/** Read the consent cookie and parse the stored state. */
|
||||
export function readConsent(): ConsentState | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const match = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith(`${COOKIE_NAME}=`));
|
||||
if (!match) return null;
|
||||
|
||||
try {
|
||||
const value = decodeURIComponent(match.split('=')[1]);
|
||||
return JSON.parse(value) as ConsentState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Write consent state to a first-party cookie. */
|
||||
export function writeConsent(
|
||||
state: ConsentState,
|
||||
expiryDays: number = 365
|
||||
): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const value = encodeURIComponent(JSON.stringify(state));
|
||||
const expires = new Date(Date.now() + expiryDays * 86400000).toUTCString();
|
||||
const secure = location.protocol === 'https:' ? '; Secure' : '';
|
||||
document.cookie = `${COOKIE_NAME}=${value}; path=/; expires=${expires}; SameSite=Lax${secure}`;
|
||||
}
|
||||
|
||||
/** Build a ConsentState for a given action. */
|
||||
export function buildConsentState(
|
||||
accepted: CategorySlug[],
|
||||
rejected: CategorySlug[],
|
||||
existingVisitorId?: string,
|
||||
tcString?: string,
|
||||
gcmState?: Record<string, 'granted' | 'denied'>,
|
||||
configVersion?: string,
|
||||
gppString?: string,
|
||||
gpcDetected?: boolean,
|
||||
gpcHonoured?: boolean,
|
||||
): ConsentState {
|
||||
return {
|
||||
visitorId: existingVisitorId ?? generateVisitorId(),
|
||||
accepted,
|
||||
rejected,
|
||||
consentedAt: new Date().toISOString(),
|
||||
bannerVersion: BANNER_VERSION,
|
||||
tcString,
|
||||
gppString,
|
||||
gcmState,
|
||||
configVersion,
|
||||
gpcDetected,
|
||||
gpcHonoured,
|
||||
};
|
||||
}
|
||||
|
||||
/** Clear the consent cookie. */
|
||||
export function clearConsent(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const secure = location.protocol === 'https:' ? '; Secure' : '';
|
||||
document.cookie = `${COOKIE_NAME}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax${secure}`;
|
||||
}
|
||||
|
||||
/** Check whether consent has been given (any state exists). */
|
||||
export function hasConsent(): boolean {
|
||||
return readConsent() !== null;
|
||||
}
|
||||
|
||||
/** Check whether a specific category has been accepted. */
|
||||
export function isCategoryAccepted(category: CategorySlug): boolean {
|
||||
const state = readConsent();
|
||||
if (!state) return category === 'necessary'; // Necessary always allowed
|
||||
return state.accepted.includes(category);
|
||||
}
|
||||
74
apps/banner/src/gcm.ts
Normal file
74
apps/banner/src/gcm.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { GcmConsentType } from './types';
|
||||
|
||||
type GcmState = Partial<Record<GcmConsentType, 'granted' | 'denied'>>;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer: unknown[];
|
||||
}
|
||||
function gtag(...args: unknown[]): void;
|
||||
}
|
||||
|
||||
/** Ensure dataLayer and gtag function exist. */
|
||||
function ensureGtag(): void {
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
if (typeof window.gtag !== 'function') {
|
||||
window.gtag = function gtag() {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
window.dataLayer.push(arguments);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Set Google Consent Mode defaults (called before any tags fire). */
|
||||
export function setGcmDefaults(defaults: GcmState): void {
|
||||
ensureGtag();
|
||||
gtag('consent', 'default', {
|
||||
...defaults,
|
||||
wait_for_update: 500,
|
||||
});
|
||||
}
|
||||
|
||||
/** Update Google Consent Mode after user makes a choice. */
|
||||
export function updateGcm(state: GcmState): void {
|
||||
ensureGtag();
|
||||
gtag('consent', 'update', state);
|
||||
}
|
||||
|
||||
/** Build the default denied state for all consent types. */
|
||||
export function buildDeniedDefaults(): GcmState {
|
||||
return {
|
||||
ad_storage: 'denied',
|
||||
ad_user_data: 'denied',
|
||||
ad_personalization: 'denied',
|
||||
analytics_storage: 'denied',
|
||||
functionality_storage: 'denied',
|
||||
personalization_storage: 'denied',
|
||||
security_storage: 'granted', // Always granted
|
||||
};
|
||||
}
|
||||
|
||||
/** Build GCM state from accepted categories. */
|
||||
export function buildGcmStateFromCategories(
|
||||
accepted: string[]
|
||||
): GcmState {
|
||||
const state = buildDeniedDefaults();
|
||||
|
||||
if (accepted.includes('functional')) {
|
||||
state.functionality_storage = 'granted';
|
||||
state.personalization_storage = 'granted';
|
||||
}
|
||||
if (accepted.includes('analytics')) {
|
||||
state.analytics_storage = 'granted';
|
||||
}
|
||||
if (accepted.includes('marketing')) {
|
||||
state.ad_storage = 'granted';
|
||||
state.ad_user_data = 'granted';
|
||||
state.ad_personalization = 'granted';
|
||||
}
|
||||
if (accepted.includes('personalisation')) {
|
||||
state.personalization_storage = 'granted';
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
148
apps/banner/src/gpc.ts
Normal file
148
apps/banner/src/gpc.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Global Privacy Control (GPC) signal detection and jurisdiction-aware
|
||||
* auto-opt-out.
|
||||
*
|
||||
* GPC is a browser-level signal (`navigator.globalPrivacyControl === true`)
|
||||
* that communicates a user's intent to opt out of the sale/sharing of their
|
||||
* personal data. Several US state laws legally require businesses to honour
|
||||
* this signal: California (CCPA/CPRA), Colorado (CPA), Connecticut (CTDPA),
|
||||
* Texas (TDPSA), and Montana (MTCDPA).
|
||||
*
|
||||
* @see https://globalprivacycontrol.github.io/gpc-spec/
|
||||
*/
|
||||
|
||||
import type { CategorySlug } from './types';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
declare global {
|
||||
interface Navigator {
|
||||
globalPrivacyControl?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of GPC detection and evaluation. */
|
||||
export interface GpcResult {
|
||||
/** Whether the browser is sending the GPC signal. */
|
||||
detected: boolean;
|
||||
/** Whether GPC was honoured (auto-opt-out applied). */
|
||||
honoured: boolean;
|
||||
/** The visitor's detected region code (e.g. 'US-CA'), if available. */
|
||||
region: string | null;
|
||||
}
|
||||
|
||||
/** GPC-related site configuration fields. */
|
||||
export interface GpcConfig {
|
||||
/** Whether to detect GPC at all. */
|
||||
gpc_enabled: boolean;
|
||||
/** Region codes where GPC is legally required. */
|
||||
gpc_jurisdictions: string[];
|
||||
/** If true, honour GPC regardless of jurisdiction. */
|
||||
gpc_global_honour: boolean;
|
||||
}
|
||||
|
||||
/** Default jurisdictions where GPC must be legally honoured. */
|
||||
export const DEFAULT_GPC_JURISDICTIONS: string[] = [
|
||||
'US-CA', // California — CCPA/CPRA
|
||||
'US-CO', // Colorado — CPA
|
||||
'US-CT', // Connecticut — CTDPA
|
||||
'US-TX', // Texas — TDPSA
|
||||
'US-MT', // Montana — MTCDPA
|
||||
];
|
||||
|
||||
// ── Detection ────────────────────────────────────────────────────────
|
||||
|
||||
/** Check whether the browser is sending the GPC signal. */
|
||||
export function isGpcEnabled(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return navigator.globalPrivacyControl === true;
|
||||
}
|
||||
|
||||
// ── Region detection ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect the visitor's region from the CMP context.
|
||||
*
|
||||
* Uses the `__cmp.visitorRegion` field, which is set by the loader
|
||||
* from GeoIP headers (e.g. Cloudflare's `CF-IPCountry` + `CF-Region`)
|
||||
* or from a GeoIP API call.
|
||||
*/
|
||||
export function getVisitorRegion(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return (window as { __cmp?: { visitorRegion?: string } }).__cmp?.visitorRegion ?? null;
|
||||
}
|
||||
|
||||
// ── Jurisdiction check ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Determine whether GPC should be honoured for the given region.
|
||||
*
|
||||
* @param region The visitor's region code (e.g. 'US-CA').
|
||||
* @param config GPC configuration from the site config.
|
||||
* @returns true if GPC should be honoured.
|
||||
*/
|
||||
export function shouldHonourGpc(
|
||||
region: string | null,
|
||||
config: GpcConfig,
|
||||
): boolean {
|
||||
if (!config.gpc_enabled) return false;
|
||||
|
||||
// Global honour overrides jurisdiction check
|
||||
if (config.gpc_global_honour) return true;
|
||||
|
||||
if (!region) return false;
|
||||
|
||||
const jurisdictions = config.gpc_jurisdictions.length > 0
|
||||
? config.gpc_jurisdictions
|
||||
: DEFAULT_GPC_JURISDICTIONS;
|
||||
|
||||
return jurisdictions.includes(region);
|
||||
}
|
||||
|
||||
// ── Auto-opt-out categories ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Categories that should be rejected when GPC is honoured.
|
||||
* GPC specifically relates to sale/sharing/targeted advertising,
|
||||
* which maps to the 'marketing' and 'personalisation' categories.
|
||||
* Analytics may also be affected depending on interpretation.
|
||||
*/
|
||||
export const GPC_OPTOUT_CATEGORIES: CategorySlug[] = [
|
||||
'marketing',
|
||||
'personalisation',
|
||||
];
|
||||
|
||||
/**
|
||||
* Categories that remain accepted when GPC auto-opt-out is applied.
|
||||
* 'necessary' is always accepted; 'functional' and 'analytics' are
|
||||
* not directly related to sale/sharing.
|
||||
*/
|
||||
export const GPC_ACCEPTED_CATEGORIES: CategorySlug[] = [
|
||||
'necessary',
|
||||
'functional',
|
||||
'analytics',
|
||||
];
|
||||
|
||||
// ── Full evaluation ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Evaluate GPC signal and determine whether to apply auto-opt-out.
|
||||
*
|
||||
* @param config GPC configuration from the site config.
|
||||
* @param region The visitor's region code, or null if unknown.
|
||||
* @returns GpcResult with detection and honouring status.
|
||||
*/
|
||||
export function evaluateGpc(
|
||||
config: GpcConfig,
|
||||
region: string | null = null,
|
||||
): GpcResult {
|
||||
const detected = isGpcEnabled();
|
||||
|
||||
if (!detected || !config.gpc_enabled) {
|
||||
return { detected, honoured: false, region };
|
||||
}
|
||||
|
||||
const honoured = shouldHonourGpc(region, config);
|
||||
|
||||
return { detected, honoured, region };
|
||||
}
|
||||
334
apps/banner/src/gpp-api.ts
Normal file
334
apps/banner/src/gpp-api.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* IAB GPP CMP API — `__gpp()` global interface.
|
||||
*
|
||||
* Implements the client-side JavaScript API that ad tech vendors call to
|
||||
* retrieve the user's consent state under the Global Privacy Platform.
|
||||
* Analogous to `__tcfapi()` for TCF v2.2.
|
||||
*
|
||||
* Supported commands: ping, getGPPData, getSection, hasSection,
|
||||
* addEventListener, removeEventListener.
|
||||
*
|
||||
* @see https://github.com/InteractiveAdvertisingBureau/Global-Privacy-Platform/blob/main/Core/CMP%20API%20Specification.md
|
||||
*/
|
||||
|
||||
import {
|
||||
type GppHeader,
|
||||
type GppString,
|
||||
type SectionData,
|
||||
type SectionDef,
|
||||
SECTION_REGISTRY,
|
||||
encodeGppString,
|
||||
} from './gpp';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__gpp?: GppApiFunction;
|
||||
__gppQueue?: GppQueueEntry[];
|
||||
}
|
||||
}
|
||||
|
||||
/** Signature of the __gpp() global function. */
|
||||
export type GppApiFunction = (
|
||||
command: string,
|
||||
callback: GppApiCallback,
|
||||
parameter?: unknown,
|
||||
) => void;
|
||||
|
||||
/** Callback passed to __gpp() by callers. */
|
||||
export type GppApiCallback = (data: unknown, success: boolean) => void;
|
||||
|
||||
/** A queued __gpp() call (from the stub). */
|
||||
export type GppQueueEntry = [string, GppApiCallback, unknown?];
|
||||
|
||||
/** Return type for the 'ping' command. */
|
||||
export interface GppPingReturn {
|
||||
gppVersion: string;
|
||||
cmpStatus: 'loaded' | 'stub';
|
||||
cmpDisplayStatus: 'visible' | 'hidden' | 'disabled';
|
||||
signalStatus: 'ready' | 'not ready';
|
||||
supportedAPIs: string[];
|
||||
cmpId: number;
|
||||
/** The current GPP string, or empty if not yet resolved. */
|
||||
gppString: string;
|
||||
/** Section IDs that apply to the current transaction. */
|
||||
applicableSections: number[];
|
||||
}
|
||||
|
||||
/** Return type for the 'getGPPData' command. */
|
||||
export interface GppData {
|
||||
/** The encoded GPP string. */
|
||||
gppString: string;
|
||||
/** Section IDs applicable to the current transaction. */
|
||||
applicableSections: number[];
|
||||
/** Parsed section data keyed by section ID. */
|
||||
parsedSections: Record<number, SectionData>;
|
||||
}
|
||||
|
||||
/** Event data sent to registered listeners. */
|
||||
export interface GppEventData {
|
||||
eventName: string;
|
||||
listenerId: number;
|
||||
data: GppData | GppPingReturn | boolean;
|
||||
pingData: GppPingReturn;
|
||||
}
|
||||
|
||||
// ── Internal state ───────────────────────────────────────────────────
|
||||
|
||||
interface GppApiState {
|
||||
cmpId: number;
|
||||
gppString: string;
|
||||
header: GppHeader | null;
|
||||
sections: Map<number, SectionData>;
|
||||
supportedAPIs: string[];
|
||||
signalStatus: 'ready' | 'not ready';
|
||||
displayStatus: 'visible' | 'hidden' | 'disabled';
|
||||
listeners: Map<number, GppApiCallback>;
|
||||
nextListenerId: number;
|
||||
}
|
||||
|
||||
let apiState: GppApiState | null = null;
|
||||
|
||||
// ── Ping builder ─────────────────────────────────────────────────────
|
||||
|
||||
function buildPingReturn(state: GppApiState): GppPingReturn {
|
||||
return {
|
||||
gppVersion: '1.1',
|
||||
cmpStatus: 'loaded',
|
||||
cmpDisplayStatus: state.displayStatus,
|
||||
signalStatus: state.signalStatus,
|
||||
supportedAPIs: state.supportedAPIs,
|
||||
cmpId: state.cmpId,
|
||||
gppString: state.gppString,
|
||||
applicableSections: state.header?.applicableSections ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildGppData(state: GppApiState): GppData {
|
||||
const parsedSections: Record<number, SectionData> = {};
|
||||
state.sections.forEach((data, id) => {
|
||||
parsedSections[id] = data;
|
||||
});
|
||||
|
||||
return {
|
||||
gppString: state.gppString,
|
||||
applicableSections: state.header?.applicableSections ?? [],
|
||||
parsedSections,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Command handler ──────────────────────────────────────────────────
|
||||
|
||||
function gppApiHandler(
|
||||
command: string,
|
||||
callback: GppApiCallback,
|
||||
parameter?: unknown,
|
||||
): void {
|
||||
if (!apiState) {
|
||||
callback(false, false);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case 'ping': {
|
||||
callback(buildPingReturn(apiState), true);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'getGPPData': {
|
||||
callback(buildGppData(apiState), true);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'getSection': {
|
||||
const prefix = parameter as string;
|
||||
if (!prefix) {
|
||||
callback(null, false);
|
||||
return;
|
||||
}
|
||||
// Find section by API prefix
|
||||
let found = false;
|
||||
for (const [id, def] of SECTION_REGISTRY.entries()) {
|
||||
if (def.apiPrefix === prefix && apiState.sections.has(id)) {
|
||||
callback(apiState.sections.get(id)!, true);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
callback(null, false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'hasSection': {
|
||||
const prefix = parameter as string;
|
||||
if (!prefix) {
|
||||
callback(false, true);
|
||||
return;
|
||||
}
|
||||
let has = false;
|
||||
for (const [id, def] of SECTION_REGISTRY.entries()) {
|
||||
if (def.apiPrefix === prefix && apiState.sections.has(id)) {
|
||||
has = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
callback(has, true);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'addEventListener': {
|
||||
const listenerId = apiState.nextListenerId++;
|
||||
apiState.listeners.set(listenerId, callback);
|
||||
// Immediately notify with current state
|
||||
const eventData: GppEventData = {
|
||||
eventName: 'listenerRegistered',
|
||||
listenerId,
|
||||
data: buildGppData(apiState),
|
||||
pingData: buildPingReturn(apiState),
|
||||
};
|
||||
try {
|
||||
callback(eventData, true);
|
||||
} catch {
|
||||
// Swallow listener errors during initial notification
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'removeEventListener': {
|
||||
const id = parameter as number;
|
||||
const removed = apiState.listeners.delete(id);
|
||||
callback(removed, removed);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
callback(false, false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Queue processing ─────────────────────────────────────────────────
|
||||
|
||||
/** Process any queued __gpp() calls from the stub installed by the loader. */
|
||||
function processQueuedCalls(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const queue = window.__gppQueue;
|
||||
if (Array.isArray(queue)) {
|
||||
for (const entry of queue) {
|
||||
gppApiHandler(entry[0], entry[1], entry[2]);
|
||||
}
|
||||
queue.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Install the __gpp() global function and process any queued calls.
|
||||
*
|
||||
* @param cmpId Registered CMP ID.
|
||||
* @param supportedAPIs List of supported API prefixes (e.g. ['usnat', 'usca']).
|
||||
*/
|
||||
export function installGppApi(
|
||||
cmpId: number,
|
||||
supportedAPIs: string[] = [],
|
||||
): void {
|
||||
apiState = {
|
||||
cmpId,
|
||||
gppString: '',
|
||||
header: null,
|
||||
sections: new Map(),
|
||||
supportedAPIs,
|
||||
signalStatus: 'not ready',
|
||||
displayStatus: 'hidden',
|
||||
listeners: new Map(),
|
||||
nextListenerId: 1,
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__gpp = gppApiHandler;
|
||||
}
|
||||
|
||||
processQueuedCalls();
|
||||
}
|
||||
|
||||
/** Remove the __gpp() global function and clear state. */
|
||||
export function removeGppApi(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
delete window.__gpp;
|
||||
delete window.__gppQueue;
|
||||
}
|
||||
apiState = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the GPP consent state and notify all listeners.
|
||||
*
|
||||
* @param gpp The full GPP string data to set.
|
||||
* @returns The encoded GPP string.
|
||||
*/
|
||||
export function updateGppConsent(gpp: GppString): string {
|
||||
const gppString = encodeGppString(gpp);
|
||||
|
||||
if (apiState) {
|
||||
apiState.gppString = gppString;
|
||||
apiState.header = gpp.header;
|
||||
apiState.sections = new Map<number, SectionData>();
|
||||
|
||||
// Copy section data (just the data, not the gpcSubsection)
|
||||
for (const [id, section] of gpp.sections.entries()) {
|
||||
apiState.sections.set(id, section.data);
|
||||
}
|
||||
|
||||
apiState.signalStatus = 'ready';
|
||||
apiState.displayStatus = 'hidden';
|
||||
|
||||
// Notify all listeners
|
||||
const ping = buildPingReturn(apiState);
|
||||
apiState.listeners.forEach((callback, listenerId) => {
|
||||
const eventData: GppEventData = {
|
||||
eventName: 'signalStatus',
|
||||
listenerId,
|
||||
data: buildGppData(apiState!),
|
||||
pingData: ping,
|
||||
};
|
||||
try {
|
||||
callback(eventData, true);
|
||||
} catch {
|
||||
// Swallow listener errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return gppString;
|
||||
}
|
||||
|
||||
/** Set the display status (visible when banner is shown). */
|
||||
export function setGppDisplayStatus(status: 'visible' | 'hidden' | 'disabled'): void {
|
||||
if (apiState) {
|
||||
apiState.displayStatus = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Set the signal status. */
|
||||
export function setGppSignalStatus(status: 'ready' | 'not ready'): void {
|
||||
if (apiState) {
|
||||
apiState.signalStatus = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the current GPP string (empty if no consent yet). */
|
||||
export function getGppString(): string {
|
||||
return apiState?.gppString ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the GPP API is currently installed.
|
||||
* Useful for conditional logic in the banner.
|
||||
*/
|
||||
export function isGppApiInstalled(): boolean {
|
||||
return apiState !== null;
|
||||
}
|
||||
601
apps/banner/src/gpp.ts
Normal file
601
apps/banner/src/gpp.ts
Normal file
@@ -0,0 +1,601 @@
|
||||
/**
|
||||
* IAB Global Privacy Platform (GPP) string encoder/decoder.
|
||||
*
|
||||
* Implements GPP v1 specification for:
|
||||
* - GPP header encoding/decoding with Fibonacci-coded section ID ranges
|
||||
* - US National Privacy section (Section 7)
|
||||
* - US state-specific sections: CA (8), VA (9), CO (10), CT (11), FL (14)
|
||||
*
|
||||
* Adding a new state section requires only adding a new SectionDef object to
|
||||
* the SECTION_REGISTRY — no structural changes needed.
|
||||
*
|
||||
* @see https://github.com/InteractiveAdvertisingBureau/Global-Privacy-Platform
|
||||
*/
|
||||
|
||||
import { BitWriter, BitReader, bytesToBase64url, base64urlToBytes } from '../../../apps/banner/src/tcf';
|
||||
|
||||
// ── GPP consent field values ─────────────────────────────────────────
|
||||
|
||||
/** Standard 2-bit consent/notice field values used across all US sections. */
|
||||
export const GppFieldValue = {
|
||||
/** Field is not applicable in this context. */
|
||||
NOT_APPLICABLE: 0,
|
||||
/** Notice was provided / user opted out. */
|
||||
YES: 1,
|
||||
/** Notice was not provided / user did not opt out. */
|
||||
NO: 2,
|
||||
} as const;
|
||||
|
||||
// ── Fibonacci coding ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pre-computed Fibonacci numbers for Zeckendorf representation.
|
||||
* F(2)=1, F(3)=2, F(4)=3, F(5)=5, ... — sufficient for any GPP section ID.
|
||||
*/
|
||||
const FIB: number[] = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597];
|
||||
|
||||
/**
|
||||
* Write a positive integer using Fibonacci coding (Zeckendorf + terminator).
|
||||
*
|
||||
* Bits are written LSB-first (position 0 = F(2)=1), with a trailing '1' bit
|
||||
* to mark the end of the code word (two consecutive 1s terminate).
|
||||
*/
|
||||
export function fibonacciEncode(writer: BitWriter, value: number): void {
|
||||
if (value < 1) {
|
||||
throw new Error('Fibonacci coding requires a positive integer (>= 1)');
|
||||
}
|
||||
|
||||
// Find the highest Fibonacci index needed
|
||||
let maxIdx = 0;
|
||||
for (let i = FIB.length - 1; i >= 0; i--) {
|
||||
if (FIB[i] <= value) {
|
||||
maxIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Build Zeckendorf representation
|
||||
const bits = new Array<boolean>(maxIdx + 1).fill(false);
|
||||
let remaining = value;
|
||||
|
||||
for (let i = maxIdx; i >= 0; i--) {
|
||||
if (FIB[i] <= remaining) {
|
||||
bits[i] = true;
|
||||
remaining -= FIB[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Write bits from position 0 upward, then the terminator '1'
|
||||
for (const bit of bits) {
|
||||
writer.writeBool(bit);
|
||||
}
|
||||
writer.writeBool(true); // terminator — creates the '11' pattern
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a Fibonacci-coded integer.
|
||||
* Reads bits until two consecutive 1-bits are encountered.
|
||||
*/
|
||||
export function fibonacciDecode(reader: BitReader): number {
|
||||
let value = 0;
|
||||
let prevBit = false;
|
||||
let position = 0;
|
||||
|
||||
while (true) {
|
||||
const bit = reader.readBool();
|
||||
if (bit && prevBit) {
|
||||
break; // Two consecutive 1s — terminator reached
|
||||
}
|
||||
if (bit) {
|
||||
if (position >= FIB.length) {
|
||||
throw new Error('Fibonacci decode: value exceeds supported range');
|
||||
}
|
||||
value += FIB[position];
|
||||
}
|
||||
prevBit = bit;
|
||||
position++;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── GPP Header ───────────────────────────────────────────────────────
|
||||
|
||||
/** GPP header type identifier (always 3). */
|
||||
const GPP_HEADER_TYPE = 3;
|
||||
|
||||
/** Current GPP specification version. */
|
||||
const GPP_VERSION = 1;
|
||||
|
||||
/** Decoded GPP header. */
|
||||
export interface GppHeader {
|
||||
/** GPP spec version (currently 1). */
|
||||
version: number;
|
||||
/** Section IDs present in this GPP string, in order. */
|
||||
sectionIds: number[];
|
||||
/** Section IDs applicable to the current transaction. */
|
||||
applicableSections: number[];
|
||||
}
|
||||
|
||||
/** Encode a GPP header to a base64url string segment. */
|
||||
export function encodeGppHeader(header: GppHeader): string {
|
||||
const writer = new BitWriter();
|
||||
|
||||
writer.writeInt(GPP_HEADER_TYPE, 6);
|
||||
writer.writeInt(header.version, 6);
|
||||
|
||||
// Section IDs — range-encoded with Fibonacci
|
||||
writeIdRange(writer, header.sectionIds);
|
||||
|
||||
// Applicable sections — range-encoded with Fibonacci
|
||||
writeIdRange(writer, header.applicableSections);
|
||||
|
||||
return bytesToBase64url(writer.toBytes());
|
||||
}
|
||||
|
||||
/** Decode a GPP header from a base64url string segment. */
|
||||
export function decodeGppHeader(encoded: string): GppHeader {
|
||||
const bytes = base64urlToBytes(encoded);
|
||||
const reader = new BitReader(bytes);
|
||||
|
||||
const type = reader.readInt(6);
|
||||
if (type !== GPP_HEADER_TYPE) {
|
||||
throw new Error(`Invalid GPP header type: ${type} (expected ${GPP_HEADER_TYPE})`);
|
||||
}
|
||||
|
||||
const version = reader.readInt(6);
|
||||
const sectionIds = readIdRange(reader);
|
||||
const applicableSections = readIdRange(reader);
|
||||
|
||||
return { version, sectionIds, applicableSections };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a list of IDs using range encoding with Fibonacci integers.
|
||||
*
|
||||
* Format:
|
||||
* NumEntries: Int(6) — number of range entries
|
||||
* For each entry:
|
||||
* IsGroup: Bool — false = single ID, true = contiguous range
|
||||
* If single: Id as Fibonacci integer
|
||||
* If group: StartId + EndId as Fibonacci integers
|
||||
*/
|
||||
function writeIdRange(writer: BitWriter, ids: number[]): void {
|
||||
if (ids.length === 0) {
|
||||
writer.writeInt(0, 6);
|
||||
return;
|
||||
}
|
||||
|
||||
// Collapse consecutive IDs into ranges
|
||||
const sorted = [...ids].sort((a, b) => a - b);
|
||||
const ranges: [number, number][] = [];
|
||||
let start = sorted[0];
|
||||
let end = sorted[0];
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (sorted[i] === end + 1) {
|
||||
end = sorted[i];
|
||||
} else {
|
||||
ranges.push([start, end]);
|
||||
start = sorted[i];
|
||||
end = sorted[i];
|
||||
}
|
||||
}
|
||||
ranges.push([start, end]);
|
||||
|
||||
writer.writeInt(ranges.length, 6);
|
||||
for (const [s, e] of ranges) {
|
||||
if (s === e) {
|
||||
writer.writeBool(false); // single
|
||||
fibonacciEncode(writer, s);
|
||||
} else {
|
||||
writer.writeBool(true); // group/range
|
||||
fibonacciEncode(writer, s);
|
||||
fibonacciEncode(writer, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a range-encoded list of IDs with Fibonacci integers. */
|
||||
function readIdRange(reader: BitReader): number[] {
|
||||
const numEntries = reader.readInt(6);
|
||||
const ids: number[] = [];
|
||||
|
||||
for (let i = 0; i < numEntries; i++) {
|
||||
const isGroup = reader.readBool();
|
||||
if (isGroup) {
|
||||
const start = fibonacciDecode(reader);
|
||||
const end = fibonacciDecode(reader);
|
||||
for (let id = start; id <= end; id++) {
|
||||
ids.push(id);
|
||||
}
|
||||
} else {
|
||||
ids.push(fibonacciDecode(reader));
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ── Section field definitions ────────────────────────────────────────
|
||||
|
||||
/** A single field in a GPP section definition. */
|
||||
export interface FieldDef {
|
||||
/** Field name (e.g. 'SaleOptOut'). */
|
||||
name: string;
|
||||
/** Bit width per value. */
|
||||
bits: number;
|
||||
/** Number of values — 1 for scalars, >1 for arrays (e.g. SensitiveDataProcessing). */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** A complete GPP section definition. */
|
||||
export interface SectionDef {
|
||||
/** IAB GPP section ID. */
|
||||
id: number;
|
||||
/** API prefix used in __gpp() calls (e.g. 'usnat', 'usca'). */
|
||||
apiPrefix: string;
|
||||
/** Section format version. */
|
||||
version: number;
|
||||
/** Ordered list of field definitions for the core segment. */
|
||||
coreFields: FieldDef[];
|
||||
/** Whether this section supports an optional GPC sub-section. */
|
||||
hasGpcSubsection: boolean;
|
||||
}
|
||||
|
||||
/** Section data — all field values as numbers (scalars) or number arrays. */
|
||||
export type SectionData = Record<string, number | number[]>;
|
||||
|
||||
// ── Field definition helpers ─────────────────────────────────────────
|
||||
|
||||
function field(name: string, bits: number): FieldDef {
|
||||
return { name, bits, count: 1 };
|
||||
}
|
||||
|
||||
function arrayField(name: string, bits: number, count: number): FieldDef {
|
||||
return { name, bits, count };
|
||||
}
|
||||
|
||||
// ── US section definitions ───────────────────────────────────────────
|
||||
|
||||
/** US National Privacy section (Section 7, usnat v1). */
|
||||
export const US_NATIONAL: SectionDef = {
|
||||
id: 7,
|
||||
apiPrefix: 'usnat',
|
||||
version: 1,
|
||||
coreFields: [
|
||||
field('Version', 6),
|
||||
field('SharingNotice', 2),
|
||||
field('SaleOptOutNotice', 2),
|
||||
field('SharingOptOutNotice', 2),
|
||||
field('TargetedAdvertisingOptOutNotice', 2),
|
||||
field('SensitiveDataProcessingOptOutNotice', 2),
|
||||
field('SensitiveDataLimitUseNotice', 2),
|
||||
field('SaleOptOut', 2),
|
||||
field('SharingOptOut', 2),
|
||||
field('TargetedAdvertisingOptOut', 2),
|
||||
arrayField('SensitiveDataProcessing', 2, 12),
|
||||
arrayField('KnownChildSensitiveDataConsents', 2, 2),
|
||||
field('PersonalDataConsents', 2),
|
||||
field('MspaCoveredTransaction', 2),
|
||||
field('MspaOptOutOptionMode', 2),
|
||||
field('MspaServiceProviderMode', 2),
|
||||
],
|
||||
hasGpcSubsection: true,
|
||||
};
|
||||
|
||||
/** US California Privacy section (Section 8, usca v1). */
|
||||
export const US_CALIFORNIA: SectionDef = {
|
||||
id: 8,
|
||||
apiPrefix: 'usca',
|
||||
version: 1,
|
||||
coreFields: [
|
||||
field('Version', 6),
|
||||
field('SaleOptOutNotice', 2),
|
||||
field('SharingOptOutNotice', 2),
|
||||
field('SensitiveDataLimitUseNotice', 2),
|
||||
field('SaleOptOut', 2),
|
||||
field('SharingOptOut', 2),
|
||||
arrayField('SensitiveDataProcessing', 2, 9),
|
||||
arrayField('KnownChildSensitiveDataConsents', 2, 2),
|
||||
field('PersonalDataConsents', 2),
|
||||
field('MspaCoveredTransaction', 2),
|
||||
field('MspaOptOutOptionMode', 2),
|
||||
field('MspaServiceProviderMode', 2),
|
||||
],
|
||||
hasGpcSubsection: true,
|
||||
};
|
||||
|
||||
/** US Virginia Privacy section (Section 9, usva v1). */
|
||||
export const US_VIRGINIA: SectionDef = {
|
||||
id: 9,
|
||||
apiPrefix: 'usva',
|
||||
version: 1,
|
||||
coreFields: [
|
||||
field('Version', 6),
|
||||
field('SharingNotice', 2),
|
||||
field('SaleOptOutNotice', 2),
|
||||
field('TargetedAdvertisingOptOutNotice', 2),
|
||||
field('SaleOptOut', 2),
|
||||
field('TargetedAdvertisingOptOut', 2),
|
||||
arrayField('SensitiveDataProcessing', 2, 8),
|
||||
field('KnownChildSensitiveDataConsents', 2),
|
||||
field('MspaCoveredTransaction', 2),
|
||||
field('MspaOptOutOptionMode', 2),
|
||||
field('MspaServiceProviderMode', 2),
|
||||
],
|
||||
hasGpcSubsection: false,
|
||||
};
|
||||
|
||||
/** US Colorado Privacy section (Section 10, usco v1). */
|
||||
export const US_COLORADO: SectionDef = {
|
||||
id: 10,
|
||||
apiPrefix: 'usco',
|
||||
version: 1,
|
||||
coreFields: [
|
||||
field('Version', 6),
|
||||
field('SharingNotice', 2),
|
||||
field('SaleOptOutNotice', 2),
|
||||
field('TargetedAdvertisingOptOutNotice', 2),
|
||||
field('SaleOptOut', 2),
|
||||
field('TargetedAdvertisingOptOut', 2),
|
||||
arrayField('SensitiveDataProcessing', 2, 7),
|
||||
field('KnownChildSensitiveDataConsents', 2),
|
||||
field('MspaCoveredTransaction', 2),
|
||||
field('MspaOptOutOptionMode', 2),
|
||||
field('MspaServiceProviderMode', 2),
|
||||
],
|
||||
hasGpcSubsection: true,
|
||||
};
|
||||
|
||||
/** US Connecticut Privacy section (Section 11, usct v1). */
|
||||
export const US_CONNECTICUT: SectionDef = {
|
||||
id: 11,
|
||||
apiPrefix: 'usct',
|
||||
version: 1,
|
||||
coreFields: [
|
||||
field('Version', 6),
|
||||
field('SharingNotice', 2),
|
||||
field('SaleOptOutNotice', 2),
|
||||
field('TargetedAdvertisingOptOutNotice', 2),
|
||||
field('SaleOptOut', 2),
|
||||
field('TargetedAdvertisingOptOut', 2),
|
||||
arrayField('SensitiveDataProcessing', 2, 8),
|
||||
arrayField('KnownChildSensitiveDataConsents', 2, 3),
|
||||
field('MspaCoveredTransaction', 2),
|
||||
field('MspaOptOutOptionMode', 2),
|
||||
field('MspaServiceProviderMode', 2),
|
||||
],
|
||||
hasGpcSubsection: true,
|
||||
};
|
||||
|
||||
/** US Florida Privacy section (Section 14, usfl v1). */
|
||||
export const US_FLORIDA: SectionDef = {
|
||||
id: 14,
|
||||
apiPrefix: 'usfl',
|
||||
version: 1,
|
||||
coreFields: [
|
||||
field('Version', 6),
|
||||
field('SharingNotice', 2),
|
||||
field('SaleOptOutNotice', 2),
|
||||
field('TargetedAdvertisingOptOutNotice', 2),
|
||||
field('SaleOptOut', 2),
|
||||
field('TargetedAdvertisingOptOut', 2),
|
||||
arrayField('SensitiveDataProcessing', 2, 8),
|
||||
arrayField('KnownChildSensitiveDataConsents', 2, 3),
|
||||
field('PersonalDataConsents', 2),
|
||||
field('MspaCoveredTransaction', 2),
|
||||
field('MspaOptOutOptionMode', 2),
|
||||
field('MspaServiceProviderMode', 2),
|
||||
],
|
||||
hasGpcSubsection: true,
|
||||
};
|
||||
|
||||
/** Registry of all known section definitions, keyed by section ID. */
|
||||
export const SECTION_REGISTRY: Map<number, SectionDef> = new Map([
|
||||
[US_NATIONAL.id, US_NATIONAL],
|
||||
[US_CALIFORNIA.id, US_CALIFORNIA],
|
||||
[US_VIRGINIA.id, US_VIRGINIA],
|
||||
[US_COLORADO.id, US_COLORADO],
|
||||
[US_CONNECTICUT.id, US_CONNECTICUT],
|
||||
[US_FLORIDA.id, US_FLORIDA],
|
||||
]);
|
||||
|
||||
// ── Section encoding/decoding ────────────────────────────────────────
|
||||
|
||||
/** GPC sub-section data (appended after a section's core segment). */
|
||||
export interface GpcSubsection {
|
||||
/** Whether the browser's Global Privacy Control signal was detected. */
|
||||
gpc: boolean;
|
||||
}
|
||||
|
||||
/** Encode a section's core fields to base64url. */
|
||||
export function encodeSectionCore(def: SectionDef, data: SectionData): string {
|
||||
const writer = new BitWriter();
|
||||
|
||||
for (const fieldDef of def.coreFields) {
|
||||
const value = data[fieldDef.name];
|
||||
if (fieldDef.count > 1) {
|
||||
const arr = (value as number[]) ?? new Array(fieldDef.count).fill(0);
|
||||
for (let i = 0; i < fieldDef.count; i++) {
|
||||
writer.writeInt(arr[i] ?? 0, fieldDef.bits);
|
||||
}
|
||||
} else {
|
||||
writer.writeInt((value as number) ?? 0, fieldDef.bits);
|
||||
}
|
||||
}
|
||||
|
||||
return bytesToBase64url(writer.toBytes());
|
||||
}
|
||||
|
||||
/** Encode a GPC sub-section to base64url. */
|
||||
export function encodeGpcSubsection(gpc: GpcSubsection): string {
|
||||
const writer = new BitWriter();
|
||||
writer.writeInt(1, 2); // SubsectionType = 1 (GPC)
|
||||
writer.writeBool(gpc.gpc);
|
||||
return bytesToBase64url(writer.toBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a full section (core + optional GPC sub-section).
|
||||
* Sub-sections are separated from the core by a '.' character.
|
||||
*/
|
||||
export function encodeSection(
|
||||
def: SectionDef,
|
||||
data: SectionData,
|
||||
gpcSubsection?: GpcSubsection,
|
||||
): string {
|
||||
const core = encodeSectionCore(def, data);
|
||||
if (def.hasGpcSubsection && gpcSubsection) {
|
||||
return `${core}.${encodeGpcSubsection(gpcSubsection)}`;
|
||||
}
|
||||
return core;
|
||||
}
|
||||
|
||||
/** Decode a section's core fields from base64url. */
|
||||
export function decodeSectionCore(def: SectionDef, encoded: string): SectionData {
|
||||
const bytes = base64urlToBytes(encoded);
|
||||
const reader = new BitReader(bytes);
|
||||
const data: SectionData = {};
|
||||
|
||||
for (const fieldDef of def.coreFields) {
|
||||
if (fieldDef.count > 1) {
|
||||
const arr: number[] = [];
|
||||
for (let i = 0; i < fieldDef.count; i++) {
|
||||
arr.push(reader.readInt(fieldDef.bits));
|
||||
}
|
||||
data[fieldDef.name] = arr;
|
||||
} else {
|
||||
data[fieldDef.name] = reader.readInt(fieldDef.bits);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Decode a GPC sub-section from base64url. */
|
||||
export function decodeGpcSubsection(encoded: string): GpcSubsection {
|
||||
const bytes = base64urlToBytes(encoded);
|
||||
const reader = new BitReader(bytes);
|
||||
reader.readInt(2); // SubsectionType — skip (always 1)
|
||||
return { gpc: reader.readBool() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a full section (core + optional GPC sub-section).
|
||||
* Splits on '.' to separate core from sub-sections.
|
||||
*/
|
||||
export function decodeSection(
|
||||
def: SectionDef,
|
||||
encoded: string,
|
||||
): { data: SectionData; gpcSubsection?: GpcSubsection } {
|
||||
const parts = encoded.split('.');
|
||||
const data = decodeSectionCore(def, parts[0]);
|
||||
let gpcSubsection: GpcSubsection | undefined;
|
||||
|
||||
if (def.hasGpcSubsection && parts.length > 1) {
|
||||
gpcSubsection = decodeGpcSubsection(parts[1]);
|
||||
}
|
||||
|
||||
return { data, gpcSubsection };
|
||||
}
|
||||
|
||||
// ── Full GPP string encoding/decoding ────────────────────────────────
|
||||
|
||||
/** A fully decoded GPP string with header and section data. */
|
||||
export interface GppString {
|
||||
header: GppHeader;
|
||||
sections: Map<number, { data: SectionData; gpcSubsection?: GpcSubsection }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a complete GPP string.
|
||||
* Format: `{header}~{section1}~{section2}~...`
|
||||
* Sections appear in the order listed in header.sectionIds.
|
||||
*/
|
||||
export function encodeGppString(gpp: GppString): string {
|
||||
const parts: string[] = [encodeGppHeader(gpp.header)];
|
||||
|
||||
for (const sectionId of gpp.header.sectionIds) {
|
||||
const section = gpp.sections.get(sectionId);
|
||||
const def = SECTION_REGISTRY.get(sectionId);
|
||||
|
||||
if (!section || !def) {
|
||||
throw new Error(`No data or definition for GPP section ${sectionId}`);
|
||||
}
|
||||
|
||||
parts.push(encodeSection(def, section.data, section.gpcSubsection));
|
||||
}
|
||||
|
||||
return parts.join('~');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a complete GPP string.
|
||||
* Splits on '~'; first segment is the header, subsequent segments are section payloads
|
||||
* matched against header.sectionIds in order.
|
||||
*/
|
||||
export function decodeGppString(gppString: string): GppString {
|
||||
const parts = gppString.split('~');
|
||||
|
||||
if (parts.length < 1) {
|
||||
throw new Error('Invalid GPP string: empty');
|
||||
}
|
||||
|
||||
const header = decodeGppHeader(parts[0]);
|
||||
const sections = new Map<number, { data: SectionData; gpcSubsection?: GpcSubsection }>();
|
||||
|
||||
for (let i = 0; i < header.sectionIds.length; i++) {
|
||||
const sectionId = header.sectionIds[i];
|
||||
const sectionPayload = parts[i + 1];
|
||||
|
||||
if (!sectionPayload) {
|
||||
throw new Error(`Missing payload for GPP section ${sectionId}`);
|
||||
}
|
||||
|
||||
const def = SECTION_REGISTRY.get(sectionId);
|
||||
if (!def) {
|
||||
// Unknown section — skip (cannot decode without a definition)
|
||||
continue;
|
||||
}
|
||||
|
||||
sections.set(sectionId, decodeSection(def, sectionPayload));
|
||||
}
|
||||
|
||||
return { header, sections };
|
||||
}
|
||||
|
||||
// ── Convenience helpers ──────────────────────────────────────────────
|
||||
|
||||
/** Create default (all-zero / N/A) data for a section, with Version pre-filled. */
|
||||
export function createDefaultSectionData(def: SectionDef): SectionData {
|
||||
const data: SectionData = {};
|
||||
|
||||
for (const fieldDef of def.coreFields) {
|
||||
if (fieldDef.name === 'Version') {
|
||||
data[fieldDef.name] = def.version;
|
||||
} else if (fieldDef.count > 1) {
|
||||
data[fieldDef.name] = new Array(fieldDef.count).fill(0);
|
||||
} else {
|
||||
data[fieldDef.name] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Look up a section definition by API prefix (e.g. 'usnat', 'usca'). */
|
||||
export function getSectionByPrefix(prefix: string): SectionDef | undefined {
|
||||
for (const def of SECTION_REGISTRY.values()) {
|
||||
if (def.apiPrefix === prefix) return def;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom section definition (for new US states or non-US sections).
|
||||
* Overwrites any existing definition with the same section ID.
|
||||
*/
|
||||
export function registerSection(def: SectionDef): void {
|
||||
SECTION_REGISTRY.set(def.id, def);
|
||||
}
|
||||
150
apps/banner/src/i18n.ts
Normal file
150
apps/banner/src/i18n.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Banner i18n — locale detection and string translation.
|
||||
*
|
||||
* Loads translations from CDN or uses built-in defaults.
|
||||
* Supports string interpolation via {{key}} placeholders.
|
||||
*/
|
||||
|
||||
export interface TranslationStrings {
|
||||
title: string;
|
||||
description: string;
|
||||
acceptAll: string;
|
||||
rejectAll: string;
|
||||
managePreferences: string;
|
||||
savePreferences: string;
|
||||
privacyPolicyLink: string;
|
||||
closeLabel: string;
|
||||
categoryNecessary: string;
|
||||
categoryNecessaryDesc: string;
|
||||
categoryFunctional: string;
|
||||
categoryFunctionalDesc: string;
|
||||
categoryAnalytics: string;
|
||||
categoryAnalyticsDesc: string;
|
||||
categoryMarketing: string;
|
||||
categoryMarketingDesc: string;
|
||||
categoryPersonalisation: string;
|
||||
categoryPersonalisationDesc: string;
|
||||
cookieCount: string;
|
||||
}
|
||||
|
||||
/** Built-in English (GB) translations — used as fallback. */
|
||||
export const DEFAULT_TRANSLATIONS: TranslationStrings = {
|
||||
title: 'We use cookies',
|
||||
description:
|
||||
'We use cookies and similar technologies to enhance your browsing experience, analyse site traffic, and personalise content. You can choose which categories to allow. [Privacy Policy]({{privacy_policy}}) [Terms & Conditions]({{terms}})',
|
||||
acceptAll: 'Accept all',
|
||||
rejectAll: 'Reject all',
|
||||
managePreferences: 'Manage preferences',
|
||||
savePreferences: 'Save preferences',
|
||||
privacyPolicyLink: 'Privacy Policy',
|
||||
closeLabel: 'Close',
|
||||
categoryNecessary: 'Necessary',
|
||||
categoryNecessaryDesc: 'Essential for the website to function. Always active.',
|
||||
categoryFunctional: 'Functional',
|
||||
categoryFunctionalDesc: 'Enable enhanced functionality and personalisation.',
|
||||
categoryAnalytics: 'Analytics',
|
||||
categoryAnalyticsDesc: 'Help us understand how visitors interact with the site.',
|
||||
categoryMarketing: 'Marketing',
|
||||
categoryMarketingDesc: 'Used to deliver personalised advertisements.',
|
||||
categoryPersonalisation: 'Personalisation',
|
||||
categoryPersonalisationDesc: 'Enable content personalisation based on your profile.',
|
||||
cookieCount: '{{count}} cookies used on this site',
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect the user's preferred locale.
|
||||
*
|
||||
* Priority: 1) explicit data-locale attribute, 2) navigator.language,
|
||||
* 3) document lang attribute, 4) 'en'.
|
||||
*/
|
||||
export function detectLocale(): string {
|
||||
// Check for explicit override on the script tag
|
||||
const scriptEl = document.querySelector('script[data-site-id]');
|
||||
const explicit = scriptEl?.getAttribute('data-locale');
|
||||
if (explicit) return normaliseLocale(explicit);
|
||||
|
||||
// Browser language
|
||||
if (typeof navigator !== 'undefined' && navigator.language) {
|
||||
return normaliseLocale(navigator.language);
|
||||
}
|
||||
|
||||
// Document lang attribute
|
||||
const docLang = document.documentElement.lang;
|
||||
if (docLang) return normaliseLocale(docLang);
|
||||
|
||||
return 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a locale string to a two-letter language code.
|
||||
* e.g. 'en-GB' → 'en', 'fr-FR' → 'fr'
|
||||
*/
|
||||
export function normaliseLocale(locale: string): string {
|
||||
return locale.split('-')[0].toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch translations for a locale from the CDN.
|
||||
* Returns null if not found or on error.
|
||||
*/
|
||||
export async function fetchTranslations(
|
||||
cdnBase: string,
|
||||
locale: string,
|
||||
): Promise<Partial<TranslationStrings> | null> {
|
||||
try {
|
||||
const resp = await fetch(`${cdnBase}/translations-${locale}.json`);
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as Partial<TranslationStrings>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load translations: try fetching from CDN, fall back to defaults.
|
||||
*/
|
||||
export async function loadTranslations(
|
||||
cdnBase: string,
|
||||
locale: string,
|
||||
): Promise<TranslationStrings> {
|
||||
if (locale === 'en') {
|
||||
return { ...DEFAULT_TRANSLATIONS };
|
||||
}
|
||||
|
||||
const remote = await fetchTranslations(cdnBase, locale);
|
||||
if (!remote) {
|
||||
return { ...DEFAULT_TRANSLATIONS };
|
||||
}
|
||||
|
||||
// Merge remote over defaults so missing keys fall back to English
|
||||
return { ...DEFAULT_TRANSLATIONS, ...remote };
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate placeholders in a translation string.
|
||||
* e.g. interpolate('{{count}} cookies', { count: '12' }) → '12 cookies'
|
||||
*/
|
||||
export function interpolate(
|
||||
template: string,
|
||||
values: Record<string, string>,
|
||||
): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => values[key] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown-style links as HTML anchor tags and strip orphaned links.
|
||||
*
|
||||
* Converts `[text](url)` to `<a href="url" ...>text</a>`.
|
||||
* If the URL is empty (because the config value wasn't set), the entire
|
||||
* `[text]()` fragment is removed so no broken links appear.
|
||||
*/
|
||||
export function renderLinks(html: string, linkClass: string = 'consentos-banner__link'): string {
|
||||
// Remove links with empty URLs (including surrounding whitespace)
|
||||
let result = html.replace(/\s*\[([^\]]*)\]\(\s*\)\s*/g, '');
|
||||
// Convert remaining markdown links to <a> tags
|
||||
result = result.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
`<a href="$2" target="_blank" rel="noopener" class="${linkClass}">$1</a>`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
199
apps/banner/src/loader.ts
Normal file
199
apps/banner/src/loader.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* consent-loader.js — Lightweight synchronous bootstrap (~2KB gzipped).
|
||||
*
|
||||
* Runs before any other scripts on the page. Responsibilities:
|
||||
* 1. Read existing consent cookie — if valid, apply consent state immediately
|
||||
* 2. Set Google Consent Mode defaults (all denied except security_storage)
|
||||
* 3. If no consent: async-load the full banner bundle
|
||||
* 4. Fetch site config from CDN/API
|
||||
*/
|
||||
|
||||
import { installBlocker, updateAcceptedCategories } from './blocker';
|
||||
import { hasConsent, readConsent } from './consent';
|
||||
import { buildDeniedDefaults, buildGcmStateFromCategories, setGcmDefaults, updateGcm } from './gcm';
|
||||
import { isGpcEnabled } from './gpc';
|
||||
import type { GppApiCallback, GppApiFunction, GppQueueEntry } from './gpp-api';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__consentos: {
|
||||
siteId: string;
|
||||
apiBase: string;
|
||||
cdnBase: string;
|
||||
loaded: boolean;
|
||||
/** Visitor region from GeoIP (e.g. 'US-CA'), set by loader. */
|
||||
visitorRegion?: string;
|
||||
/** Whether GPC signal was detected by the loader. */
|
||||
gpcDetected?: boolean;
|
||||
};
|
||||
/** Public ConsentOS API for site integration. */
|
||||
ConsentOS: {
|
||||
/**
|
||||
* Identify a user by providing their third-party JWT.
|
||||
* Syncs consent with the server-side profile.
|
||||
* Returns categories that still need consent (empty if fully resolved).
|
||||
*/
|
||||
identifyUser: (jwt: string) => Promise<string[]>;
|
||||
/** Clear the identified user session (revert to anonymous). */
|
||||
clearIdentity: () => void;
|
||||
/**
|
||||
* Re-open the banner so the visitor can review, change, or
|
||||
* withdraw their consent. Pre-fills category toggles from the
|
||||
* current stored consent state. Required by GDPR Art. 7(3)
|
||||
* ("it shall be as easy to withdraw as to give consent").
|
||||
*/
|
||||
showPreferences: () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
(function consentosLoader() {
|
||||
// Read data attributes from the script tag, falling back to
|
||||
// window.__consentos if attributes are absent (e.g. GTM injectScript).
|
||||
const scriptEl = document.currentScript as HTMLScriptElement | null;
|
||||
const gtmConfig = (window as any).__consentos;
|
||||
const siteId = scriptEl?.getAttribute('data-site-id') ?? gtmConfig?.siteId ?? '';
|
||||
const apiBase = scriptEl?.getAttribute('data-api-base') ?? gtmConfig?.apiBase ?? '';
|
||||
|
||||
// Derive cdnBase: explicit attribute > apiBase > same origin as this script
|
||||
const scriptSrc = scriptEl?.getAttribute('src') ?? '';
|
||||
let scriptOrigin = '';
|
||||
try {
|
||||
if (scriptSrc) {
|
||||
scriptOrigin = new URL(scriptSrc, window.location.href).origin;
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL — fall through to empty string
|
||||
}
|
||||
const cdnBase = scriptEl?.getAttribute('data-cdn-base') ?? (apiBase || scriptOrigin);
|
||||
|
||||
// Expose global CMP context
|
||||
window.__consentos = {
|
||||
siteId,
|
||||
apiBase,
|
||||
cdnBase,
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
// Expose public CMP API — methods are stubs until the full bundle loads
|
||||
// and replaces them with real implementations.
|
||||
window.ConsentOS = {
|
||||
identifyUser: async () => {
|
||||
console.warn('[ConsentOS] identifyUser called before bundle loaded — queuing');
|
||||
return [];
|
||||
},
|
||||
clearIdentity: () => {
|
||||
console.warn('[ConsentOS] clearIdentity called before bundle loaded');
|
||||
},
|
||||
showPreferences: () => {
|
||||
console.warn('[ConsentOS] showPreferences called before bundle loaded');
|
||||
},
|
||||
};
|
||||
|
||||
// Warn if essential attributes are missing
|
||||
if (!siteId) {
|
||||
console.warn('[ConsentOS] Missing data-site-id attribute on the consent-loader script tag');
|
||||
}
|
||||
if (!apiBase) {
|
||||
console.warn('[ConsentOS] Missing data-api-base attribute — consent recording will not work');
|
||||
}
|
||||
|
||||
// 1. Install script/cookie blocker immediately (before any third-party scripts)
|
||||
installBlocker();
|
||||
|
||||
// 1b. Install __gpp stub — queues calls until the full bundle loads
|
||||
installGppStub();
|
||||
|
||||
// 2. Set GCM defaults immediately (must happen before gtag tags fire)
|
||||
setGcmDefaults(buildDeniedDefaults());
|
||||
|
||||
// 2b. Detect GPC signal early and store on __cmp for the banner bundle
|
||||
window.__consentos.gpcDetected = isGpcEnabled();
|
||||
|
||||
// 3. Check for existing consent
|
||||
const existingConsent = readConsent();
|
||||
|
||||
if (existingConsent) {
|
||||
// Consent already given — update blocker, GCM, and we're done
|
||||
updateAcceptedCategories(existingConsent.accepted as import('./types').CategorySlug[]);
|
||||
const gcmState = buildGcmStateFromCategories(existingConsent.accepted);
|
||||
updateGcm(gcmState);
|
||||
|
||||
// Fire consent-change event so GTM/other scripts know
|
||||
dispatchConsentEvent(existingConsent.accepted);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. No consent — async-load the full banner bundle
|
||||
loadBannerBundle(cdnBase);
|
||||
})();
|
||||
|
||||
/** Async-load the full consent banner bundle. */
|
||||
function loadBannerBundle(cdnBase: string): void {
|
||||
const script = document.createElement('script');
|
||||
// Mark as allowed so the blocker's MutationObserver doesn't intercept it
|
||||
script.setAttribute('data-consentos-allowed', 'true');
|
||||
script.src = `${cdnBase}/consent-bundle.js`;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
window.__consentos.loaded = true;
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.error(`[ConsentOS] Failed to load consent bundle from ${cdnBase}/consent-bundle.js`);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a lightweight __gpp() stub that queues calls until the full
|
||||
* banner bundle loads and replaces it with the real implementation.
|
||||
*/
|
||||
function installGppStub(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (window.__gpp) return; // Already installed
|
||||
|
||||
const queue: GppQueueEntry[] = [];
|
||||
window.__gppQueue = queue;
|
||||
|
||||
const stub: GppApiFunction = function gppStub(
|
||||
command: string,
|
||||
callback: GppApiCallback,
|
||||
parameter?: unknown,
|
||||
) {
|
||||
if (command === 'ping') {
|
||||
callback(
|
||||
{
|
||||
gppVersion: '1.1',
|
||||
cmpStatus: 'stub',
|
||||
cmpDisplayStatus: 'hidden',
|
||||
signalStatus: 'not ready',
|
||||
supportedAPIs: [],
|
||||
cmpId: 0,
|
||||
gppString: '',
|
||||
applicableSections: [],
|
||||
},
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
queue.push([command, callback, parameter]);
|
||||
};
|
||||
|
||||
window.__gpp = stub;
|
||||
}
|
||||
|
||||
/** Dispatch a custom event with accepted categories. */
|
||||
function dispatchConsentEvent(accepted: string[]): void {
|
||||
const event = new CustomEvent('consentos:consent-change', {
|
||||
detail: { accepted },
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
// Also push to dataLayer for GTM
|
||||
if (typeof window.dataLayer !== 'undefined') {
|
||||
window.dataLayer.push({
|
||||
event: 'consentos_consent_change',
|
||||
cmp_accepted_categories: accepted,
|
||||
});
|
||||
}
|
||||
}
|
||||
68
apps/banner/src/reconsent.ts
Normal file
68
apps/banner/src/reconsent.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Re-consent logic — determines whether the user needs to re-consent.
|
||||
*
|
||||
* Triggers re-consent when:
|
||||
* 1. Consent has expired (based on consent_expiry_days)
|
||||
* 2. The banner/config version has changed since last consent
|
||||
*/
|
||||
|
||||
import type { ConsentState, SiteConfig } from './types';
|
||||
|
||||
/** Check whether the existing consent has expired based on consent_expiry_days. */
|
||||
export function isConsentExpired(
|
||||
consent: ConsentState,
|
||||
config: SiteConfig,
|
||||
): boolean {
|
||||
const consentedAt = new Date(consent.consentedAt).getTime();
|
||||
if (isNaN(consentedAt)) return true;
|
||||
|
||||
const expiryMs = config.consent_expiry_days * 86_400_000;
|
||||
return Date.now() > consentedAt + expiryMs;
|
||||
}
|
||||
|
||||
/** Check whether the config version has changed since consent was given. */
|
||||
export function hasConfigVersionChanged(
|
||||
consent: ConsentState,
|
||||
config: SiteConfig,
|
||||
): boolean {
|
||||
// If the consent doesn't record a config version, treat as needing re-consent
|
||||
// only if the config has a version set
|
||||
if (!consent.configVersion) {
|
||||
return config.id !== '';
|
||||
}
|
||||
|
||||
return consent.configVersion !== config.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user needs to re-consent.
|
||||
*
|
||||
* Returns an object with a boolean and the specific reason(s) so the
|
||||
* banner can log or report why re-consent was triggered.
|
||||
*/
|
||||
export function needsReconsent(
|
||||
consent: ConsentState,
|
||||
config: SiteConfig,
|
||||
): ReconsentResult {
|
||||
const reasons: ReconsentReason[] = [];
|
||||
|
||||
if (isConsentExpired(consent, config)) {
|
||||
reasons.push('expired');
|
||||
}
|
||||
|
||||
if (hasConfigVersionChanged(consent, config)) {
|
||||
reasons.push('config_changed');
|
||||
}
|
||||
|
||||
return {
|
||||
required: reasons.length > 0,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
|
||||
export type ReconsentReason = 'expired' | 'config_changed';
|
||||
|
||||
export interface ReconsentResult {
|
||||
required: boolean;
|
||||
reasons: ReconsentReason[];
|
||||
}
|
||||
310
apps/banner/src/reporter.ts
Normal file
310
apps/banner/src/reporter.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Client-side cookie reporter.
|
||||
*
|
||||
* Runs on a configurable sampling basis (e.g. 10% of page loads).
|
||||
* Enumerates document.cookie, localStorage, and sessionStorage keys.
|
||||
* Batches reports and POSTs to the scanner/report API endpoint.
|
||||
*
|
||||
* @module reporter
|
||||
*/
|
||||
|
||||
/** A single discovered storage item from the client. */
|
||||
export interface DiscoveredCookie {
|
||||
name: string;
|
||||
domain: string;
|
||||
storage_type: 'cookie' | 'local_storage' | 'session_storage';
|
||||
/** Raw value length (not the value itself, for privacy). */
|
||||
value_length: number;
|
||||
/** HTTP cookie attributes if available. */
|
||||
path?: string;
|
||||
is_secure?: boolean;
|
||||
same_site?: string;
|
||||
/** Script URL that likely set this cookie (from PerformanceObserver). */
|
||||
script_source?: string;
|
||||
}
|
||||
|
||||
/** Report payload sent to the API. */
|
||||
export interface CookieReport {
|
||||
site_id: string;
|
||||
page_url: string;
|
||||
cookies: DiscoveredCookie[];
|
||||
/** ISO 8601 timestamp of when the report was collected. */
|
||||
collected_at: string;
|
||||
/** User agent string for classification context. */
|
||||
user_agent: string;
|
||||
}
|
||||
|
||||
/** Reporter configuration. */
|
||||
export interface ReporterConfig {
|
||||
/** Site ID for this report. */
|
||||
siteId: string;
|
||||
/** Base URL for the API (e.g. https://api.example.com/api/v1). */
|
||||
apiBase: string;
|
||||
/** Sampling rate: 0.0 to 1.0 (e.g. 0.1 = 10% of page loads). */
|
||||
sampleRate: number;
|
||||
/** Delay in ms before collecting cookies (allows page to load). */
|
||||
collectDelay: number;
|
||||
/** Whether to include localStorage keys. */
|
||||
includeLocalStorage: boolean;
|
||||
/** Whether to include sessionStorage keys. */
|
||||
includeSessionStorage: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: Omit<ReporterConfig, 'siteId' | 'apiBase'> = {
|
||||
sampleRate: 0.1,
|
||||
collectDelay: 3000,
|
||||
includeLocalStorage: true,
|
||||
includeSessionStorage: true,
|
||||
};
|
||||
|
||||
/** Track loaded scripts for attribution via PerformanceObserver. */
|
||||
let observedScripts: string[] = [];
|
||||
let observer: PerformanceObserver | null = null;
|
||||
|
||||
/**
|
||||
* Install a PerformanceObserver to track script loads for attribution.
|
||||
* Must be called early (ideally in the loader) to capture all scripts.
|
||||
*/
|
||||
export function installScriptObserver(): void {
|
||||
if (typeof PerformanceObserver === 'undefined') return;
|
||||
|
||||
try {
|
||||
observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.entryType === 'resource') {
|
||||
const resourceEntry = entry as PerformanceResourceTiming;
|
||||
if (resourceEntry.initiatorType === 'script') {
|
||||
observedScripts.push(resourceEntry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe({ type: 'resource', buffered: true });
|
||||
} catch {
|
||||
// PerformanceObserver not supported — degrade gracefully
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the script observer. Used for testing and cleanup.
|
||||
*/
|
||||
export function removeScriptObserver(): void {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
observedScripts = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scripts observed since the observer was installed.
|
||||
*/
|
||||
export function getObservedScripts(): string[] {
|
||||
return [...observedScripts];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse document.cookie into individual cookies.
|
||||
*/
|
||||
export function parseCookies(): DiscoveredCookie[] {
|
||||
const cookies: DiscoveredCookie[] = [];
|
||||
|
||||
if (typeof document === 'undefined' || !document.cookie) return cookies;
|
||||
|
||||
const cookieStr = document.cookie;
|
||||
const pairs = cookieStr.split(';');
|
||||
|
||||
for (const pair of pairs) {
|
||||
const trimmed = pair.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex < 0) continue;
|
||||
|
||||
const name = trimmed.substring(0, eqIndex).trim();
|
||||
const value = trimmed.substring(eqIndex + 1);
|
||||
|
||||
if (!name) continue;
|
||||
|
||||
cookies.push({
|
||||
name,
|
||||
domain: window.location.hostname,
|
||||
storage_type: 'cookie',
|
||||
value_length: value.length,
|
||||
});
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate localStorage keys.
|
||||
*/
|
||||
export function enumerateLocalStorage(): DiscoveredCookie[] {
|
||||
const items: DiscoveredCookie[] = [];
|
||||
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') return items;
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key) continue;
|
||||
|
||||
const value = localStorage.getItem(key) ?? '';
|
||||
items.push({
|
||||
name: key,
|
||||
domain: window.location.hostname,
|
||||
storage_type: 'local_storage',
|
||||
value_length: value.length,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be blocked in some browsers/contexts
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate sessionStorage keys.
|
||||
*/
|
||||
export function enumerateSessionStorage(): DiscoveredCookie[] {
|
||||
const items: DiscoveredCookie[] = [];
|
||||
|
||||
try {
|
||||
if (typeof sessionStorage === 'undefined') return items;
|
||||
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (!key) continue;
|
||||
|
||||
const value = sessionStorage.getItem(key) ?? '';
|
||||
items.push({
|
||||
name: key,
|
||||
domain: window.location.hostname,
|
||||
storage_type: 'session_storage',
|
||||
value_length: value.length,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage may be blocked in some browsers/contexts
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all storage items from the current page.
|
||||
*/
|
||||
export function collectAll(config: ReporterConfig): DiscoveredCookie[] {
|
||||
const items: DiscoveredCookie[] = [];
|
||||
|
||||
items.push(...parseCookies());
|
||||
|
||||
if (config.includeLocalStorage) {
|
||||
items.push(...enumerateLocalStorage());
|
||||
}
|
||||
|
||||
if (config.includeSessionStorage) {
|
||||
items.push(...enumerateSessionStorage());
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the report payload.
|
||||
*/
|
||||
export function buildReport(
|
||||
config: ReporterConfig,
|
||||
cookies: DiscoveredCookie[],
|
||||
): CookieReport {
|
||||
return {
|
||||
site_id: config.siteId,
|
||||
page_url: typeof window !== 'undefined' ? window.location.href : '',
|
||||
cookies,
|
||||
collected_at: new Date().toISOString(),
|
||||
user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a cookie report to the API.
|
||||
* Uses navigator.sendBeacon for reliability, falling back to fetch.
|
||||
*/
|
||||
export async function sendReport(
|
||||
apiBase: string,
|
||||
report: CookieReport,
|
||||
): Promise<boolean> {
|
||||
const url = `${apiBase}/scanner/report`;
|
||||
const body = JSON.stringify(report);
|
||||
|
||||
// Prefer sendBeacon — fires even if page is closing
|
||||
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
||||
const blob = new Blob([body], { type: 'application/json' });
|
||||
return navigator.sendBeacon(url, blob);
|
||||
}
|
||||
|
||||
// Fallback to fetch
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
keepalive: true,
|
||||
});
|
||||
return resp.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if this page load should be sampled for reporting.
|
||||
*/
|
||||
export function shouldSample(sampleRate: number): boolean {
|
||||
return Math.random() < sampleRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the reporter. Call once per page load.
|
||||
*
|
||||
* The reporter will:
|
||||
* 1. Check the sampling rate — skip if not sampled
|
||||
* 2. Wait for the configured delay (to allow scripts to run)
|
||||
* 3. Enumerate all cookies and storage keys
|
||||
* 4. POST the report to the scanner API
|
||||
*/
|
||||
export function startReporter(config: Partial<ReporterConfig> & { siteId: string; apiBase: string }): void {
|
||||
const fullConfig: ReporterConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
if (!shouldSample(fullConfig.sampleRate)) return;
|
||||
|
||||
// Install script observer for attribution
|
||||
installScriptObserver();
|
||||
|
||||
// Delay collection to allow page to finish loading
|
||||
setTimeout(() => {
|
||||
const cookies = collectAll(fullConfig);
|
||||
if (cookies.length === 0) return;
|
||||
|
||||
const report = buildReport(fullConfig, cookies);
|
||||
sendReport(fullConfig.apiBase, report);
|
||||
}, fullConfig.collectDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect and report immediately (for testing or manual triggers).
|
||||
*/
|
||||
export async function reportNow(
|
||||
config: Partial<ReporterConfig> & { siteId: string; apiBase: string },
|
||||
): Promise<CookieReport | null> {
|
||||
const fullConfig: ReporterConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
const cookies = collectAll(fullConfig);
|
||||
|
||||
if (cookies.length === 0) return null;
|
||||
|
||||
const report = buildReport(fullConfig, cookies);
|
||||
await sendReport(fullConfig.apiBase, report);
|
||||
return report;
|
||||
}
|
||||
88
apps/banner/src/shopify.ts
Normal file
88
apps/banner/src/shopify.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Shopify Customer Privacy API integration.
|
||||
*
|
||||
* Bridges CMP consent decisions to Shopify's Customer Privacy API
|
||||
* (`window.Shopify.customerPrivacy`). When enabled, the CMP calls
|
||||
* `setTrackingConsent()` with the mapped consent state whenever
|
||||
* the visitor makes a consent choice.
|
||||
*
|
||||
* @see https://shopify.dev/docs/api/customer-privacy
|
||||
*/
|
||||
|
||||
import type { CategorySlug } from './types';
|
||||
|
||||
/** Shopify consent values: empty = unknown, 'yes' = granted, 'no' = denied. */
|
||||
type ShopifyConsent = '' | 'yes' | 'no';
|
||||
|
||||
/** The consent object Shopify expects. */
|
||||
export interface ShopifyTrackingConsent {
|
||||
analytics: ShopifyConsent;
|
||||
marketing: ShopifyConsent;
|
||||
preferences: ShopifyConsent;
|
||||
sale_of_data: ShopifyConsent;
|
||||
}
|
||||
|
||||
/** Shape of window.Shopify.customerPrivacy when loaded. */
|
||||
interface ShopifyCustomerPrivacy {
|
||||
setTrackingConsent: (consent: ShopifyTrackingConsent, callback?: () => void) => void;
|
||||
currentVisitorConsent: () => ShopifyTrackingConsent;
|
||||
analyticsProcessingAllowed: () => boolean;
|
||||
marketingAllowed: () => boolean;
|
||||
preferencesProcessingAllowed: () => boolean;
|
||||
saleOfDataAllowed: () => boolean;
|
||||
getRegion: () => string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Shopify?: {
|
||||
customerPrivacy?: ShopifyCustomerPrivacy;
|
||||
loadFeatures?: (
|
||||
features: Array<{ name: string; version: string }>,
|
||||
callback: (error?: Error) => void,
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Check whether the Shopify Customer Privacy API is available. */
|
||||
export function isShopifyPrivacyAvailable(): boolean {
|
||||
return typeof window.Shopify?.customerPrivacy?.setTrackingConsent === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map CMP accepted categories to Shopify consent signals.
|
||||
*
|
||||
* Category mapping:
|
||||
* functional → preferences
|
||||
* analytics → analytics
|
||||
* marketing → marketing + sale_of_data
|
||||
* personalisation → sale_of_data (if marketing not already accepted)
|
||||
*/
|
||||
export function buildShopifyConsent(accepted: CategorySlug[]): ShopifyTrackingConsent {
|
||||
return {
|
||||
preferences: accepted.includes('functional') ? 'yes' : 'no',
|
||||
analytics: accepted.includes('analytics') ? 'yes' : 'no',
|
||||
marketing: accepted.includes('marketing') ? 'yes' : 'no',
|
||||
sale_of_data:
|
||||
accepted.includes('marketing') || accepted.includes('personalisation') ? 'yes' : 'no',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Push consent state to the Shopify Customer Privacy API.
|
||||
*
|
||||
* This should be called after the user makes a consent choice, only
|
||||
* when `shopify_privacy_enabled` is true in the site config.
|
||||
*
|
||||
* If the API is not yet loaded, the call is silently skipped — Shopify's
|
||||
* own consent tracking will pick up the state on next page load.
|
||||
*/
|
||||
export function updateShopifyConsent(accepted: CategorySlug[]): void {
|
||||
if (!isShopifyPrivacyAvailable()) return;
|
||||
|
||||
const consent = buildShopifyConsent(accepted);
|
||||
window.Shopify!.customerPrivacy!.setTrackingConsent(consent, () => {
|
||||
/* Consent registered with Shopify */
|
||||
});
|
||||
}
|
||||
749
apps/banner/src/tcf.ts
Normal file
749
apps/banner/src/tcf.ts
Normal file
@@ -0,0 +1,749 @@
|
||||
/**
|
||||
* IAB TCF v2.2 — TC string encoder/decoder and __tcfapi interface.
|
||||
*
|
||||
* Implements the Transparency & Consent Framework v2.2 specification:
|
||||
* - Encode/decode TC strings (base64url-encoded bitfields)
|
||||
* - Standard __tcfapi interface (getTCData, ping, addEventListener, removeEventListener)
|
||||
*
|
||||
* @see https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework
|
||||
*/
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__tcfapi?: (
|
||||
command: string,
|
||||
version: number,
|
||||
callback: TcfApiCallback,
|
||||
parameter?: unknown
|
||||
) => void;
|
||||
__tcfapiQueue?: unknown[][];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Core TC string data model. */
|
||||
export interface TCModel {
|
||||
/** TC string format version — always 2 for TCF v2.x. */
|
||||
version: number;
|
||||
/** Deciseconds since epoch when consent was first created. */
|
||||
created: number;
|
||||
/** Deciseconds since epoch when consent was last updated. */
|
||||
lastUpdated: number;
|
||||
/** Registered CMP ID from the IAB CMP List. */
|
||||
cmpId: number;
|
||||
/** CMP version. */
|
||||
cmpVersion: number;
|
||||
/** Screen number in the CMP where consent was collected. */
|
||||
consentScreen: number;
|
||||
/** ISO 639-1 two-letter language code (upper-case). */
|
||||
consentLanguage: string;
|
||||
/** Version of the GVL used to create this TC string. */
|
||||
vendorListVersion: number;
|
||||
/** TCF policy version — 4 for TCF v2.2. */
|
||||
tcfPolicyVersion: number;
|
||||
/** Whether this TC string is specific to this service. */
|
||||
isServiceSpecific: boolean;
|
||||
/** Whether non-standard texts were used (deprecated in 2.2). */
|
||||
useNonStandardTexts: boolean;
|
||||
/** Opted-in special feature IDs (1-indexed). */
|
||||
specialFeatureOptIns: Set<number>;
|
||||
/** Consented purpose IDs (1-indexed, up to 24). */
|
||||
purposeConsents: Set<number>;
|
||||
/** Purpose IDs for which legitimate interest is established. */
|
||||
purposeLegitimateInterests: Set<number>;
|
||||
/** Whether Purpose 1 was NOT disclosed at time of consent (EU-specific). */
|
||||
purposeOneTreatment: boolean;
|
||||
/** ISO 3166-1 alpha-2 publisher country code. */
|
||||
publisherCC: string;
|
||||
/** Vendor IDs for which consent is given. */
|
||||
vendorConsents: Set<number>;
|
||||
/** Vendor IDs for which legitimate interest is established. */
|
||||
vendorLegitimateInterests: Set<number>;
|
||||
/** Publisher restrictions by purpose. */
|
||||
publisherRestrictions: PublisherRestriction[];
|
||||
}
|
||||
|
||||
/** A publisher restriction entry. */
|
||||
export interface PublisherRestriction {
|
||||
purposeId: number;
|
||||
restrictionType: RestrictionType;
|
||||
vendorIds: Set<number>;
|
||||
}
|
||||
|
||||
/** Restriction type values per the TCF spec. */
|
||||
export enum RestrictionType {
|
||||
/** Purpose is flatly not allowed by publisher. */
|
||||
NOT_ALLOWED = 0,
|
||||
/** Require consent (override LI). */
|
||||
REQUIRE_CONSENT = 1,
|
||||
/** Require legitimate interest (override consent). */
|
||||
REQUIRE_LEGITIMATE_INTEREST = 2,
|
||||
}
|
||||
|
||||
/** Return type for the __tcfapi getTCData command. */
|
||||
export interface TCData {
|
||||
tcString: string;
|
||||
tcfPolicyVersion: number;
|
||||
cmpId: number;
|
||||
cmpVersion: number;
|
||||
gdprApplies: boolean;
|
||||
eventStatus: 'tcloaded' | 'cmpuishown' | 'useractioncomplete';
|
||||
cmpStatus: 'loaded' | 'error';
|
||||
listenerId?: number;
|
||||
isServiceSpecific: boolean;
|
||||
useNonStandardTexts: boolean;
|
||||
publisherCC: string;
|
||||
purposeOneTreatment: boolean;
|
||||
purpose: {
|
||||
consents: Record<string, boolean>;
|
||||
legitimateInterests: Record<string, boolean>;
|
||||
};
|
||||
vendor: {
|
||||
consents: Record<string, boolean>;
|
||||
legitimateInterests: Record<string, boolean>;
|
||||
};
|
||||
specialFeatureOptins: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/** Return type for the __tcfapi ping command. */
|
||||
export interface PingReturn {
|
||||
gdprApplies: boolean;
|
||||
cmpLoaded: boolean;
|
||||
cmpStatus: 'loaded' | 'error' | 'stub';
|
||||
displayStatus: 'visible' | 'hidden' | 'disabled';
|
||||
apiVersion: string;
|
||||
cmpVersion: number;
|
||||
cmpId: number;
|
||||
gvlVersion: number;
|
||||
tcfPolicyVersion: number;
|
||||
}
|
||||
|
||||
/** Callback type for __tcfapi commands. */
|
||||
export type TcfApiCallback = (result: TCData | PingReturn | boolean, success: boolean) => void;
|
||||
|
||||
// ── Bit manipulation ─────────────────────────────────────────────────
|
||||
|
||||
/** Writes bits sequentially into a byte buffer. */
|
||||
export class BitWriter {
|
||||
private bytes: number[] = [];
|
||||
private currentByte = 0;
|
||||
private bitIndex = 0;
|
||||
|
||||
/** Write `length` bits from `value` (MSB first). */
|
||||
writeInt(value: number, length: number): void {
|
||||
for (let i = length - 1; i >= 0; i--) {
|
||||
const bit = (value >>> i) & 1;
|
||||
this.currentByte = (this.currentByte << 1) | bit;
|
||||
this.bitIndex++;
|
||||
if (this.bitIndex === 8) {
|
||||
this.bytes.push(this.currentByte);
|
||||
this.currentByte = 0;
|
||||
this.bitIndex = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a single boolean bit. */
|
||||
writeBool(value: boolean): void {
|
||||
this.writeInt(value ? 1 : 0, 1);
|
||||
}
|
||||
|
||||
/** Write a Set of IDs as a bitfield of `maxId` bits. */
|
||||
writeBitfield(ids: Set<number>, maxId: number): void {
|
||||
for (let i = 1; i <= maxId; i++) {
|
||||
this.writeBool(ids.has(i));
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a two-letter code as 2 × 6-bit values (A=0, B=1, ...). */
|
||||
writeLetters(code: string): void {
|
||||
const upper = code.toUpperCase();
|
||||
this.writeInt(upper.charCodeAt(0) - 65, 6);
|
||||
this.writeInt(upper.charCodeAt(1) - 65, 6);
|
||||
}
|
||||
|
||||
/** Finalise and return the accumulated bytes (padding the last byte). */
|
||||
toBytes(): Uint8Array {
|
||||
const result = new Uint8Array(this.bytes.length + (this.bitIndex > 0 ? 1 : 0));
|
||||
for (let i = 0; i < this.bytes.length; i++) {
|
||||
result[i] = this.bytes[i];
|
||||
}
|
||||
if (this.bitIndex > 0) {
|
||||
result[this.bytes.length] = this.currentByte << (8 - this.bitIndex);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads bits sequentially from a byte buffer. */
|
||||
export class BitReader {
|
||||
private bytes: Uint8Array;
|
||||
private bitOffset = 0;
|
||||
|
||||
constructor(bytes: Uint8Array) {
|
||||
this.bytes = bytes;
|
||||
}
|
||||
|
||||
/** Read `length` bits as an unsigned integer. */
|
||||
readInt(length: number): number {
|
||||
let value = 0;
|
||||
for (let i = 0; i < length; i++) {
|
||||
const byteIndex = (this.bitOffset + i) >> 3;
|
||||
const bitPosition = 7 - ((this.bitOffset + i) & 7);
|
||||
if (byteIndex < this.bytes.length) {
|
||||
value = (value << 1) | ((this.bytes[byteIndex] >> bitPosition) & 1);
|
||||
} else {
|
||||
value = value << 1;
|
||||
}
|
||||
}
|
||||
this.bitOffset += length;
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Read a single bit as boolean. */
|
||||
readBool(): boolean {
|
||||
return this.readInt(1) === 1;
|
||||
}
|
||||
|
||||
/** Read a bitfield of `maxId` bits into a Set. */
|
||||
readBitfield(maxId: number): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
for (let i = 1; i <= maxId; i++) {
|
||||
if (this.readBool()) {
|
||||
ids.add(i);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Read 2 × 6-bit letters as a two-char string. */
|
||||
readLetters(): string {
|
||||
const a = this.readInt(6);
|
||||
const b = this.readInt(6);
|
||||
return String.fromCharCode(a + 65, b + 65);
|
||||
}
|
||||
|
||||
/** Check whether there are at least `bits` remaining. */
|
||||
hasRemaining(bits: number): boolean {
|
||||
return this.bitOffset + bits <= this.bytes.length * 8;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Base64url encoding ───────────────────────────────────────────────
|
||||
|
||||
const B64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
|
||||
/** Encode bytes to websafe base64 (no padding). */
|
||||
export function bytesToBase64url(bytes: Uint8Array): string {
|
||||
let result = '';
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const b0 = bytes[i];
|
||||
const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
||||
const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
||||
const triple = (b0 << 16) | (b1 << 8) | b2;
|
||||
|
||||
result += B64_CHARS[(triple >> 18) & 0x3f];
|
||||
result += B64_CHARS[(triple >> 12) & 0x3f];
|
||||
if (i + 1 < bytes.length) result += B64_CHARS[(triple >> 6) & 0x3f];
|
||||
if (i + 2 < bytes.length) result += B64_CHARS[triple & 0x3f];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Decode websafe base64 (no padding) to bytes. */
|
||||
export function base64urlToBytes(str: string): Uint8Array {
|
||||
const lookup = new Uint8Array(128);
|
||||
for (let i = 0; i < B64_CHARS.length; i++) {
|
||||
lookup[B64_CHARS.charCodeAt(i)] = i;
|
||||
}
|
||||
|
||||
// Calculate output length (account for missing padding)
|
||||
const paddedLen = str.length + ((4 - (str.length % 4)) % 4);
|
||||
const byteLen = (paddedLen * 3) / 4 - (paddedLen - str.length);
|
||||
const bytes = new Uint8Array(byteLen);
|
||||
|
||||
let j = 0;
|
||||
for (let i = 0; i < str.length; i += 4) {
|
||||
const a = lookup[str.charCodeAt(i)] ?? 0;
|
||||
const b = i + 1 < str.length ? (lookup[str.charCodeAt(i + 1)] ?? 0) : 0;
|
||||
const c = i + 2 < str.length ? (lookup[str.charCodeAt(i + 2)] ?? 0) : 0;
|
||||
const d = i + 3 < str.length ? (lookup[str.charCodeAt(i + 3)] ?? 0) : 0;
|
||||
|
||||
if (j < byteLen) bytes[j++] = (a << 2) | (b >> 4);
|
||||
if (j < byteLen) bytes[j++] = ((b & 0xf) << 4) | (c >> 2);
|
||||
if (j < byteLen) bytes[j++] = ((c & 0x3) << 6) | d;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// ── Encoding ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Number of purpose bits defined by the TCF spec. */
|
||||
const NUM_PURPOSES = 24;
|
||||
/** Number of special feature bits. */
|
||||
const NUM_SPECIAL_FEATURES = 12;
|
||||
|
||||
/** Convert a JS timestamp (ms) to TCF deciseconds. */
|
||||
export function msToDeciseconds(ms: number): number {
|
||||
return Math.round(ms / 100);
|
||||
}
|
||||
|
||||
/** Convert TCF deciseconds to a JS timestamp (ms). */
|
||||
export function decisecondsToMs(ds: number): number {
|
||||
return ds * 100;
|
||||
}
|
||||
|
||||
/** Create a default (empty) TCModel with sensible defaults. */
|
||||
export function createTCModel(overrides?: Partial<TCModel>): TCModel {
|
||||
const now = msToDeciseconds(Date.now());
|
||||
return {
|
||||
version: 2,
|
||||
created: now,
|
||||
lastUpdated: now,
|
||||
cmpId: 0,
|
||||
cmpVersion: 1,
|
||||
consentScreen: 1,
|
||||
consentLanguage: 'EN',
|
||||
vendorListVersion: 0,
|
||||
tcfPolicyVersion: 4,
|
||||
isServiceSpecific: true,
|
||||
useNonStandardTexts: false,
|
||||
specialFeatureOptIns: new Set(),
|
||||
purposeConsents: new Set(),
|
||||
purposeLegitimateInterests: new Set(),
|
||||
purposeOneTreatment: false,
|
||||
publisherCC: 'GB',
|
||||
vendorConsents: new Set(),
|
||||
vendorLegitimateInterests: new Set(),
|
||||
publisherRestrictions: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Encode a TCModel into a TC string (core segment only). */
|
||||
export function encodeTCString(model: TCModel): string {
|
||||
const writer = new BitWriter();
|
||||
|
||||
// Core segment
|
||||
writer.writeInt(model.version, 6);
|
||||
writer.writeInt(model.created, 36);
|
||||
writer.writeInt(model.lastUpdated, 36);
|
||||
writer.writeInt(model.cmpId, 12);
|
||||
writer.writeInt(model.cmpVersion, 12);
|
||||
writer.writeInt(model.consentScreen, 6);
|
||||
writer.writeLetters(model.consentLanguage);
|
||||
writer.writeInt(model.vendorListVersion, 12);
|
||||
writer.writeInt(model.tcfPolicyVersion, 6);
|
||||
writer.writeBool(model.isServiceSpecific);
|
||||
writer.writeBool(model.useNonStandardTexts);
|
||||
writer.writeBitfield(model.specialFeatureOptIns, NUM_SPECIAL_FEATURES);
|
||||
writer.writeBitfield(model.purposeConsents, NUM_PURPOSES);
|
||||
writer.writeBitfield(model.purposeLegitimateInterests, NUM_PURPOSES);
|
||||
writer.writeBool(model.purposeOneTreatment);
|
||||
writer.writeLetters(model.publisherCC);
|
||||
|
||||
// Vendor consent section (bitfield encoding)
|
||||
const maxVendorConsent = maxId(model.vendorConsents);
|
||||
writer.writeInt(maxVendorConsent, 16);
|
||||
if (maxVendorConsent > 0) {
|
||||
writer.writeBool(false); // IsRangeEncoding = false (bitfield)
|
||||
writer.writeBitfield(model.vendorConsents, maxVendorConsent);
|
||||
}
|
||||
|
||||
// Vendor legitimate interest section (bitfield encoding)
|
||||
const maxVendorLI = maxId(model.vendorLegitimateInterests);
|
||||
writer.writeInt(maxVendorLI, 16);
|
||||
if (maxVendorLI > 0) {
|
||||
writer.writeBool(false); // IsRangeEncoding = false (bitfield)
|
||||
writer.writeBitfield(model.vendorLegitimateInterests, maxVendorLI);
|
||||
}
|
||||
|
||||
// Publisher restrictions
|
||||
writer.writeInt(model.publisherRestrictions.length, 12);
|
||||
for (const restriction of model.publisherRestrictions) {
|
||||
writer.writeInt(restriction.purposeId, 6);
|
||||
writer.writeInt(restriction.restrictionType, 2);
|
||||
// Encode vendor IDs as ranges
|
||||
const sortedVendors = [...restriction.vendorIds].sort((a, b) => a - b);
|
||||
const ranges = toRanges(sortedVendors);
|
||||
writer.writeInt(ranges.length, 12);
|
||||
for (const [start, end] of ranges) {
|
||||
const isRange = start !== end;
|
||||
writer.writeBool(isRange);
|
||||
writer.writeInt(start, 16);
|
||||
if (isRange) {
|
||||
writer.writeInt(end, 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bytesToBase64url(writer.toBytes());
|
||||
}
|
||||
|
||||
/** Decode a TC string (core segment) back into a TCModel. */
|
||||
export function decodeTCString(tcString: string): TCModel {
|
||||
// Only parse the core segment (first dot-separated part)
|
||||
const coreSegment = tcString.split('.')[0];
|
||||
const bytes = base64urlToBytes(coreSegment);
|
||||
const reader = new BitReader(bytes);
|
||||
|
||||
const version = reader.readInt(6);
|
||||
const created = reader.readInt(36);
|
||||
const lastUpdated = reader.readInt(36);
|
||||
const cmpId = reader.readInt(12);
|
||||
const cmpVersion = reader.readInt(12);
|
||||
const consentScreen = reader.readInt(6);
|
||||
const consentLanguage = reader.readLetters();
|
||||
const vendorListVersion = reader.readInt(12);
|
||||
const tcfPolicyVersion = reader.readInt(6);
|
||||
const isServiceSpecific = reader.readBool();
|
||||
const useNonStandardTexts = reader.readBool();
|
||||
const specialFeatureOptIns = reader.readBitfield(NUM_SPECIAL_FEATURES);
|
||||
const purposeConsents = reader.readBitfield(NUM_PURPOSES);
|
||||
const purposeLegitimateInterests = reader.readBitfield(NUM_PURPOSES);
|
||||
const purposeOneTreatment = reader.readBool();
|
||||
const publisherCC = reader.readLetters();
|
||||
|
||||
// Vendor consents
|
||||
let vendorConsents = new Set<number>();
|
||||
const maxVendorConsent = reader.readInt(16);
|
||||
if (maxVendorConsent > 0) {
|
||||
const isRange = reader.readBool();
|
||||
if (isRange) {
|
||||
vendorConsents = readRangeEntries(reader);
|
||||
} else {
|
||||
vendorConsents = reader.readBitfield(maxVendorConsent);
|
||||
}
|
||||
}
|
||||
|
||||
// Vendor legitimate interests
|
||||
let vendorLegitimateInterests = new Set<number>();
|
||||
const maxVendorLI = reader.readInt(16);
|
||||
if (maxVendorLI > 0) {
|
||||
const isRange = reader.readBool();
|
||||
if (isRange) {
|
||||
vendorLegitimateInterests = readRangeEntries(reader);
|
||||
} else {
|
||||
vendorLegitimateInterests = reader.readBitfield(maxVendorLI);
|
||||
}
|
||||
}
|
||||
|
||||
// Publisher restrictions
|
||||
const publisherRestrictions: PublisherRestriction[] = [];
|
||||
if (reader.hasRemaining(12)) {
|
||||
const numRestrictions = reader.readInt(12);
|
||||
for (let i = 0; i < numRestrictions; i++) {
|
||||
const purposeId = reader.readInt(6);
|
||||
const restrictionType = reader.readInt(2) as RestrictionType;
|
||||
const numRanges = reader.readInt(12);
|
||||
const vendorIds = new Set<number>();
|
||||
for (let j = 0; j < numRanges; j++) {
|
||||
const isRangeEntry = reader.readBool();
|
||||
const startId = reader.readInt(16);
|
||||
if (isRangeEntry) {
|
||||
const endId = reader.readInt(16);
|
||||
for (let v = startId; v <= endId; v++) vendorIds.add(v);
|
||||
} else {
|
||||
vendorIds.add(startId);
|
||||
}
|
||||
}
|
||||
publisherRestrictions.push({ purposeId, restrictionType, vendorIds });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
created,
|
||||
lastUpdated,
|
||||
cmpId,
|
||||
cmpVersion,
|
||||
consentScreen,
|
||||
consentLanguage,
|
||||
vendorListVersion,
|
||||
tcfPolicyVersion,
|
||||
isServiceSpecific,
|
||||
useNonStandardTexts,
|
||||
specialFeatureOptIns,
|
||||
purposeConsents,
|
||||
purposeLegitimateInterests,
|
||||
purposeOneTreatment,
|
||||
publisherCC,
|
||||
vendorConsents,
|
||||
vendorLegitimateInterests,
|
||||
publisherRestrictions,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Range helpers ────────────────────────────────────────────────────
|
||||
|
||||
/** Read range-encoded vendor entries from a BitReader. */
|
||||
function readRangeEntries(reader: BitReader): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
const numEntries = reader.readInt(12);
|
||||
for (let i = 0; i < numEntries; i++) {
|
||||
const isRange = reader.readBool();
|
||||
const startId = reader.readInt(16);
|
||||
if (isRange) {
|
||||
const endId = reader.readInt(16);
|
||||
for (let v = startId; v <= endId; v++) ids.add(v);
|
||||
} else {
|
||||
ids.add(startId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Convert sorted vendor IDs to contiguous [start, end] ranges. */
|
||||
function toRanges(sorted: number[]): [number, number][] {
|
||||
if (sorted.length === 0) return [];
|
||||
const ranges: [number, number][] = [];
|
||||
let start = sorted[0];
|
||||
let end = sorted[0];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (sorted[i] === end + 1) {
|
||||
end = sorted[i];
|
||||
} else {
|
||||
ranges.push([start, end]);
|
||||
start = sorted[i];
|
||||
end = sorted[i];
|
||||
}
|
||||
}
|
||||
ranges.push([start, end]);
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/** Get the maximum ID from a Set, or 0 if empty. */
|
||||
function maxId(ids: Set<number>): number {
|
||||
let max = 0;
|
||||
ids.forEach((id) => {
|
||||
if (id > max) max = id;
|
||||
});
|
||||
return max;
|
||||
}
|
||||
|
||||
// ── __tcfapi interface ───────────────────────────────────────────────
|
||||
|
||||
interface TcfApiState {
|
||||
cmpId: number;
|
||||
cmpVersion: number;
|
||||
gdprApplies: boolean;
|
||||
tcModel: TCModel | null;
|
||||
tcString: string;
|
||||
displayStatus: 'visible' | 'hidden' | 'disabled';
|
||||
listeners: Map<number, TcfApiCallback>;
|
||||
nextListenerId: number;
|
||||
}
|
||||
|
||||
let apiState: TcfApiState | null = null;
|
||||
|
||||
/** Build a TCData response from the current state. */
|
||||
function buildTCData(
|
||||
state: TcfApiState,
|
||||
eventStatus: TCData['eventStatus'],
|
||||
listenerId?: number
|
||||
): TCData {
|
||||
const model = state.tcModel;
|
||||
const purposeConsents: Record<string, boolean> = {};
|
||||
const purposeLI: Record<string, boolean> = {};
|
||||
const vendorConsents: Record<string, boolean> = {};
|
||||
const vendorLI: Record<string, boolean> = {};
|
||||
const specialFeatures: Record<string, boolean> = {};
|
||||
|
||||
if (model) {
|
||||
for (let i = 1; i <= NUM_PURPOSES; i++) {
|
||||
purposeConsents[String(i)] = model.purposeConsents.has(i);
|
||||
purposeLI[String(i)] = model.purposeLegitimateInterests.has(i);
|
||||
}
|
||||
const maxVC = maxId(model.vendorConsents);
|
||||
const maxVLI = maxId(model.vendorLegitimateInterests);
|
||||
const vendorMax = Math.max(maxVC, maxVLI);
|
||||
for (let i = 1; i <= vendorMax; i++) {
|
||||
vendorConsents[String(i)] = model.vendorConsents.has(i);
|
||||
vendorLI[String(i)] = model.vendorLegitimateInterests.has(i);
|
||||
}
|
||||
for (let i = 1; i <= NUM_SPECIAL_FEATURES; i++) {
|
||||
specialFeatures[String(i)] = model.specialFeatureOptIns.has(i);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tcString: state.tcString,
|
||||
tcfPolicyVersion: model?.tcfPolicyVersion ?? 4,
|
||||
cmpId: state.cmpId,
|
||||
cmpVersion: state.cmpVersion,
|
||||
gdprApplies: state.gdprApplies,
|
||||
eventStatus,
|
||||
cmpStatus: 'loaded',
|
||||
listenerId,
|
||||
isServiceSpecific: model?.isServiceSpecific ?? true,
|
||||
useNonStandardTexts: model?.useNonStandardTexts ?? false,
|
||||
publisherCC: model?.publisherCC ?? 'GB',
|
||||
purposeOneTreatment: model?.purposeOneTreatment ?? false,
|
||||
purpose: {
|
||||
consents: purposeConsents,
|
||||
legitimateInterests: purposeLI,
|
||||
},
|
||||
vendor: {
|
||||
consents: vendorConsents,
|
||||
legitimateInterests: vendorLI,
|
||||
},
|
||||
specialFeatureOptins: specialFeatures,
|
||||
};
|
||||
}
|
||||
|
||||
/** The __tcfapi function handler. */
|
||||
function tcfApiHandler(
|
||||
command: string,
|
||||
version: number,
|
||||
callback: TcfApiCallback,
|
||||
_parameter?: unknown
|
||||
): void {
|
||||
if (!apiState) {
|
||||
callback(false, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (version !== 2) {
|
||||
callback(false, false);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case 'ping': {
|
||||
const pingReturn: PingReturn = {
|
||||
gdprApplies: apiState.gdprApplies,
|
||||
cmpLoaded: true,
|
||||
cmpStatus: 'loaded',
|
||||
displayStatus: apiState.displayStatus,
|
||||
apiVersion: '2.2',
|
||||
cmpVersion: apiState.cmpVersion,
|
||||
cmpId: apiState.cmpId,
|
||||
gvlVersion: apiState.tcModel?.vendorListVersion ?? 0,
|
||||
tcfPolicyVersion: apiState.tcModel?.tcfPolicyVersion ?? 4,
|
||||
};
|
||||
callback(pingReturn, true);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'getTCData': {
|
||||
const eventStatus = apiState.tcString ? 'tcloaded' : 'cmpuishown';
|
||||
callback(buildTCData(apiState, eventStatus), true);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'addEventListener': {
|
||||
const listenerId = apiState.nextListenerId++;
|
||||
apiState.listeners.set(listenerId, callback);
|
||||
const eventStatus = apiState.tcString ? 'tcloaded' : 'cmpuishown';
|
||||
try {
|
||||
callback(buildTCData(apiState, eventStatus, listenerId), true);
|
||||
} catch {
|
||||
// Swallow listener errors during initial notification
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'removeEventListener': {
|
||||
// _parameter is the listenerId to remove
|
||||
const id = _parameter as number;
|
||||
const removed = apiState.listeners.delete(id);
|
||||
callback(removed, removed);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
callback(false, false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Process any queued __tcfapi calls from the stub. */
|
||||
function processQueuedCalls(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const queue = window.__tcfapiQueue;
|
||||
if (Array.isArray(queue)) {
|
||||
for (const args of queue) {
|
||||
tcfApiHandler(
|
||||
args[0] as string,
|
||||
args[1] as number,
|
||||
args[2] as TcfApiCallback,
|
||||
args[3]
|
||||
);
|
||||
}
|
||||
queue.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the __tcfapi global function and process any queued calls.
|
||||
*
|
||||
* @param cmpId Registered CMP ID from the IAB CMP List.
|
||||
* @param cmpVersion CMP version number.
|
||||
* @param gdprApplies Whether GDPR applies to the current user.
|
||||
*/
|
||||
export function installTcfApi(
|
||||
cmpId: number,
|
||||
cmpVersion: number,
|
||||
gdprApplies: boolean = true
|
||||
): void {
|
||||
apiState = {
|
||||
cmpId,
|
||||
cmpVersion,
|
||||
gdprApplies,
|
||||
tcModel: null,
|
||||
tcString: '',
|
||||
displayStatus: 'hidden',
|
||||
listeners: new Map(),
|
||||
nextListenerId: 1,
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__tcfapi = tcfApiHandler;
|
||||
}
|
||||
|
||||
processQueuedCalls();
|
||||
}
|
||||
|
||||
/** Remove the __tcfapi global. */
|
||||
export function removeTcfApi(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
delete window.__tcfapi;
|
||||
delete window.__tcfapiQueue;
|
||||
}
|
||||
apiState = null;
|
||||
}
|
||||
|
||||
/** Update the TCF state and notify all listeners. */
|
||||
export function updateTcfConsent(model: TCModel): string {
|
||||
const tcString = encodeTCString(model);
|
||||
|
||||
if (apiState) {
|
||||
apiState.tcModel = model;
|
||||
apiState.tcString = tcString;
|
||||
apiState.displayStatus = 'hidden';
|
||||
|
||||
// Notify all listeners
|
||||
apiState.listeners.forEach((callback, listenerId) => {
|
||||
try {
|
||||
callback(buildTCData(apiState!, 'useractioncomplete', listenerId), true);
|
||||
} catch {
|
||||
// Swallow listener errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return tcString;
|
||||
}
|
||||
|
||||
/** Set the display status (visible when banner is shown). */
|
||||
export function setTcfDisplayStatus(status: 'visible' | 'hidden' | 'disabled'): void {
|
||||
if (apiState) {
|
||||
apiState.displayStatus = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the current TC string (empty if no consent yet). */
|
||||
export function getTcString(): string {
|
||||
return apiState?.tcString ?? '';
|
||||
}
|
||||
151
apps/banner/src/types.ts
Normal file
151
apps/banner/src/types.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/** Consent category slugs. */
|
||||
export type CategorySlug =
|
||||
| 'necessary'
|
||||
| 'functional'
|
||||
| 'analytics'
|
||||
| 'marketing'
|
||||
| 'personalisation';
|
||||
|
||||
/** Consent state stored in the first-party cookie. */
|
||||
export interface ConsentState {
|
||||
/** Unique visitor identifier. */
|
||||
visitorId: string;
|
||||
/** Categories the visitor has accepted. */
|
||||
accepted: CategorySlug[];
|
||||
/** Categories the visitor has rejected. */
|
||||
rejected: CategorySlug[];
|
||||
/** ISO 8601 timestamp of consent. */
|
||||
consentedAt: string;
|
||||
/** Version of the banner that collected consent. */
|
||||
bannerVersion: string;
|
||||
/** TC string if TCF is enabled. */
|
||||
tcString?: string;
|
||||
/** GPP string if GPP is enabled. */
|
||||
gppString?: string;
|
||||
/** Google Consent Mode state at time of consent. */
|
||||
gcmState?: Record<string, 'granted' | 'denied'>;
|
||||
/** Config version (site_config ID) at time of consent, used for re-consent detection. */
|
||||
configVersion?: string;
|
||||
/** Whether GPC signal was detected in the browser. */
|
||||
gpcDetected?: boolean;
|
||||
/** Whether GPC signal was honoured (auto-opt-out applied). */
|
||||
gpcHonoured?: boolean;
|
||||
}
|
||||
|
||||
/** Server-side consent profile returned by the sync API. */
|
||||
export interface ServerConsentProfile {
|
||||
id: string;
|
||||
org_id: string;
|
||||
consent_group_id: string | null;
|
||||
user_identifier: string;
|
||||
categories_consented: string[];
|
||||
categories_rejected: string[];
|
||||
tc_string: string | null;
|
||||
gpp_string: string | null;
|
||||
gcm_state: Record<string, 'granted' | 'denied'> | null;
|
||||
last_updated_at: string;
|
||||
last_site_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** A/B test variant as delivered in site config. */
|
||||
export interface ABTestVariant {
|
||||
id: string;
|
||||
name: string;
|
||||
traffic_percentage: number;
|
||||
banner_config_override: Partial<BannerConfig> | null;
|
||||
is_control: boolean;
|
||||
}
|
||||
|
||||
/** Active A/B test data included in site config. */
|
||||
export interface ABTestConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
variants: ABTestVariant[];
|
||||
}
|
||||
|
||||
/** Site configuration fetched from the API/CDN. */
|
||||
export interface SiteConfig {
|
||||
id: string;
|
||||
site_id: string;
|
||||
blocking_mode: 'opt_in' | 'opt_out' | 'informational';
|
||||
regional_modes: Record<string, string> | null;
|
||||
tcf_enabled: boolean;
|
||||
gpp_enabled: boolean;
|
||||
gpp_supported_apis: string[];
|
||||
gpc_enabled: boolean;
|
||||
gpc_jurisdictions: string[];
|
||||
gpc_global_honour: boolean;
|
||||
gcm_enabled: boolean;
|
||||
gcm_default: Record<string, 'granted' | 'denied'> | null;
|
||||
shopify_privacy_enabled: boolean;
|
||||
banner_config: BannerConfig | null;
|
||||
privacy_policy_url: string | null;
|
||||
terms_url: string | null;
|
||||
consent_expiry_days: number;
|
||||
/** Consent group ID for cross-domain sync (null if not in a group). */
|
||||
consent_group_id: string | null;
|
||||
/** Active A/B test (null if none running). */
|
||||
ab_test: ABTestConfig | null;
|
||||
/** Initiator map: root script URL → category for root-level blocking. */
|
||||
initiator_map: InitiatorMapping[] | null;
|
||||
}
|
||||
|
||||
/** Maps a root initiator script to the cookie category it ultimately sets. */
|
||||
export interface InitiatorMapping {
|
||||
/** Root script URL pattern (matched against script src). */
|
||||
root_script: string;
|
||||
/** Category of cookies set by this initiator chain. */
|
||||
category: CategorySlug;
|
||||
}
|
||||
|
||||
/** Per-button styling configuration. */
|
||||
export interface ButtonConfig {
|
||||
backgroundColour?: string;
|
||||
textColour?: string;
|
||||
borderColour?: string;
|
||||
style?: 'filled' | 'outline' | 'text';
|
||||
}
|
||||
|
||||
/** Text content configuration for the banner. */
|
||||
export interface BannerTextConfig {
|
||||
title?: string;
|
||||
description?: string;
|
||||
acceptAll?: string;
|
||||
rejectAll?: string;
|
||||
managePreferences?: string;
|
||||
savePreferences?: string;
|
||||
}
|
||||
|
||||
/** Visual configuration for the banner. */
|
||||
export interface BannerConfig {
|
||||
displayMode?: 'bottom_banner' | 'top_banner' | 'overlay' | 'corner_popup';
|
||||
cornerPosition?: 'left' | 'right';
|
||||
primaryColour?: string;
|
||||
backgroundColour?: string;
|
||||
textColour?: string;
|
||||
buttonStyle?: 'filled' | 'outline';
|
||||
fontFamily?: string;
|
||||
customFontUrl?: string;
|
||||
borderRadius?: number;
|
||||
showLogo?: boolean;
|
||||
logoUrl?: string;
|
||||
showRejectAll?: boolean;
|
||||
showManagePreferences?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
showCookieCount?: boolean;
|
||||
acceptButton?: ButtonConfig;
|
||||
rejectButton?: ButtonConfig;
|
||||
manageButton?: ButtonConfig;
|
||||
text?: BannerTextConfig;
|
||||
}
|
||||
|
||||
/** Google Consent Mode consent types. */
|
||||
export type GcmConsentType =
|
||||
| 'ad_storage'
|
||||
| 'ad_user_data'
|
||||
| 'ad_personalization'
|
||||
| 'analytics_storage'
|
||||
| 'functionality_storage'
|
||||
| 'personalization_storage'
|
||||
| 'security_storage';
|
||||
Reference in New Issue
Block a user