Files
consentos/apps/banner/src/__tests__/banner.test.ts
James Cottrill fbf26453f2 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.
2026-04-14 09:18:18 +00:00

369 lines
12 KiB
TypeScript

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');
});
});
});