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.
502 lines
16 KiB
TypeScript
502 lines
16 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|
|
});
|