Initial reliable release of consent engine
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# Universal Cookie Consent — Jurisdiction Engine v1
|
||||
|
||||
Stage 3.2 introduces a pure, deterministic Jurisdiction Engine.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
The engine resolves a jurisdiction context and maps it to a policy family.
|
||||
It deliberately does **not**:
|
||||
|
||||
- read IP addresses;
|
||||
- call geolocation APIs;
|
||||
- access browser location;
|
||||
- inspect GPC;
|
||||
- enforce consent;
|
||||
- modify the Core;
|
||||
- integrate GTM;
|
||||
- provide legal advice.
|
||||
|
||||
## Modes
|
||||
|
||||
- `fixed`: the site explicitly selects a jurisdiction.
|
||||
- `auto`: the site supports multiple jurisdictions and supplies available signals.
|
||||
- `global`: the site deliberately selects the global baseline for everyone.
|
||||
|
||||
## Resolution priority
|
||||
|
||||
`site-config` > `server` > `application` > `browser` > `fallback`
|
||||
|
||||
The resolver is deterministic and uses only supplied signals.
|
||||
|
||||
## Initial catalog
|
||||
|
||||
- `global`
|
||||
- `us`
|
||||
- `us-ma`
|
||||
- `us-ca`
|
||||
- `eu`
|
||||
|
||||
The policy catalog is intentionally a structural placeholder. Detailed legal requirements will be researched and added in a later policy stage rather than being guessed or embedded in the resolver.
|
||||
|
||||
## Tests
|
||||
|
||||
Node regression:
|
||||
|
||||
```bash
|
||||
node tests/jurisdiction-test.mjs
|
||||
```
|
||||
|
||||
Browser Test Lab:
|
||||
|
||||
```bash
|
||||
python -m http.server 8000
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
`http://localhost:8000/tests/jurisdiction-test.html`
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Cookie Consent Core
|
||||
* categories.js
|
||||
*
|
||||
* Defines the consent categories and normalizes category state.
|
||||
* This module has no DOM, storage, network, or GTM dependencies.
|
||||
*/
|
||||
|
||||
export const CATEGORY_NAMES = Object.freeze([
|
||||
"necessary",
|
||||
"preferences",
|
||||
"analytics",
|
||||
"marketing"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Legacy/default denied state kept as a public export for compatibility.
|
||||
* New/undecided visitors should use createUnknownCategories().
|
||||
*/
|
||||
export const DEFAULT_CATEGORIES = Object.freeze({
|
||||
necessary: "granted",
|
||||
preferences: "denied",
|
||||
analytics: "denied",
|
||||
marketing: "denied"
|
||||
});
|
||||
|
||||
const VALID_STATES = new Set(["unknown", "granted", "denied"]);
|
||||
|
||||
export function createDefaultCategories() {
|
||||
return { ...DEFAULT_CATEGORIES };
|
||||
}
|
||||
|
||||
export function createUnknownCategories() {
|
||||
return {
|
||||
necessary: "granted",
|
||||
preferences: "unknown",
|
||||
analytics: "unknown",
|
||||
marketing: "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidCategory(category) {
|
||||
return CATEGORY_NAMES.includes(category);
|
||||
}
|
||||
|
||||
export function isValidState(state) {
|
||||
return VALID_STATES.has(state);
|
||||
}
|
||||
|
||||
export function normalizeCategories(input = {}, fallback = DEFAULT_CATEGORIES) {
|
||||
const source = input && typeof input === "object" ? input : {};
|
||||
const fallbackState =
|
||||
fallback && typeof fallback === "object"
|
||||
? fallback
|
||||
: DEFAULT_CATEGORIES;
|
||||
|
||||
const normalized = {
|
||||
necessary: "granted",
|
||||
preferences: "denied",
|
||||
analytics: "denied",
|
||||
marketing: "denied"
|
||||
};
|
||||
|
||||
for (const category of CATEGORY_NAMES) {
|
||||
if (category === "necessary") {
|
||||
normalized.necessary = "granted";
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = source[category];
|
||||
|
||||
if (isValidState(candidate)) {
|
||||
normalized[category] = candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackValue = fallbackState[category];
|
||||
normalized[category] = isValidState(fallbackValue)
|
||||
? fallbackValue
|
||||
: "denied";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict validation used for persisted or externally supplied category
|
||||
* objects. Unlike normalizeCategories(), this function never repairs data.
|
||||
*/
|
||||
export function isValidCategoriesObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const category of CATEGORY_NAMES) {
|
||||
if (!Object.prototype.hasOwnProperty.call(value, category)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (value.necessary !== "granted") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return CATEGORY_NAMES
|
||||
.filter((category) => category !== "necessary")
|
||||
.every((category) => isValidState(value[category]));
|
||||
}
|
||||
|
||||
export function mergeCategories(current, changes) {
|
||||
const base = normalizeCategories(current, DEFAULT_CATEGORIES);
|
||||
const incoming = changes && typeof changes === "object" ? changes : {};
|
||||
const merged = { ...base };
|
||||
|
||||
for (const category of CATEGORY_NAMES) {
|
||||
if (category === "necessary") {
|
||||
if (Object.prototype.hasOwnProperty.call(incoming, category) &&
|
||||
incoming[category] !== "granted") {
|
||||
throw new TypeError(
|
||||
`Invalid consent state for "necessary". ` +
|
||||
`The necessary category must always be "granted".`
|
||||
);
|
||||
}
|
||||
|
||||
merged.necessary = "granted";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(incoming, category)) {
|
||||
const value = incoming[category];
|
||||
|
||||
if (!isValidState(value)) {
|
||||
throw new TypeError(
|
||||
`Invalid consent state for "${category}". ` +
|
||||
`Expected "unknown", "granted", or "denied".`
|
||||
);
|
||||
}
|
||||
|
||||
merged[category] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function createAcceptedCategories() {
|
||||
return {
|
||||
necessary: "granted",
|
||||
preferences: "granted",
|
||||
analytics: "granted",
|
||||
marketing: "granted"
|
||||
};
|
||||
}
|
||||
|
||||
export function createRejectedCategories() {
|
||||
return {
|
||||
necessary: "granted",
|
||||
preferences: "denied",
|
||||
analytics: "denied",
|
||||
marketing: "denied"
|
||||
};
|
||||
}
|
||||
|
||||
export function hasAcceptedAll(categories) {
|
||||
const state = normalizeCategories(categories, DEFAULT_CATEGORIES);
|
||||
|
||||
return CATEGORY_NAMES
|
||||
.filter((category) => category !== "necessary")
|
||||
.every((category) => state[category] === "granted");
|
||||
}
|
||||
|
||||
export function hasRejectedAll(categories) {
|
||||
const state = normalizeCategories(categories, DEFAULT_CATEGORIES);
|
||||
|
||||
return CATEGORY_NAMES
|
||||
.filter((category) => category !== "necessary")
|
||||
.every((category) => state[category] === "denied");
|
||||
}
|
||||
|
||||
export function isDecisionComplete(categories) {
|
||||
const state = normalizeCategories(categories, createUnknownCategories());
|
||||
|
||||
return CATEGORY_NAMES
|
||||
.filter((category) => category !== "necessary")
|
||||
.every((category) => state[category] !== "unknown");
|
||||
}
|
||||
|
||||
export function toPermissionMap(categories) {
|
||||
const state = normalizeCategories(categories, DEFAULT_CATEGORIES);
|
||||
|
||||
return Object.fromEntries(
|
||||
CATEGORY_NAMES.map((category) => [
|
||||
category,
|
||||
state[category] === "granted"
|
||||
])
|
||||
);
|
||||
}
|
||||
+551
@@ -0,0 +1,551 @@
|
||||
/**
|
||||
* Cookie Consent Core
|
||||
* consent.js
|
||||
*
|
||||
* Main consent engine.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - initialize consent state
|
||||
* - load/save persisted consent
|
||||
* - manage categories
|
||||
* - manage policy version
|
||||
* - expose consent operations
|
||||
* - emit lifecycle events
|
||||
*
|
||||
* Deliberately excluded:
|
||||
* - DOM/UI
|
||||
* - GTM
|
||||
* - geolocation
|
||||
* - network requests
|
||||
* - legal/jurisdiction rules
|
||||
*/
|
||||
|
||||
import {
|
||||
createDefaultCategories,
|
||||
createUnknownCategories,
|
||||
createAcceptedCategories,
|
||||
createRejectedCategories,
|
||||
normalizeCategories,
|
||||
mergeCategories,
|
||||
hasAcceptedAll,
|
||||
hasRejectedAll,
|
||||
isDecisionComplete,
|
||||
isValidCategoriesObject,
|
||||
toPermissionMap
|
||||
} from "./categories.js";
|
||||
|
||||
import {
|
||||
createCookieStorage,
|
||||
isStoragePayload,
|
||||
STORAGE_DEFAULTS
|
||||
} from "./storage.js";
|
||||
|
||||
import { createEventBus } from "./events.js";
|
||||
|
||||
export const ENGINE_VERSION = "1.0.1";
|
||||
export const DEFAULT_POLICY_VERSION = "2026-01";
|
||||
export const DEFAULT_JURISDICTION = "global";
|
||||
|
||||
const DEFAULT_CONFIG = Object.freeze({
|
||||
policyVersion: DEFAULT_POLICY_VERSION,
|
||||
jurisdiction: DEFAULT_JURISDICTION,
|
||||
requireDecision: true,
|
||||
storage: {
|
||||
name: STORAGE_DEFAULTS.name,
|
||||
duration: STORAGE_DEFAULTS.duration,
|
||||
path: STORAGE_DEFAULTS.path,
|
||||
sameSite: STORAGE_DEFAULTS.sameSite
|
||||
}
|
||||
});
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value)
|
||||
);
|
||||
}
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function mergeConfig(base, overrides) {
|
||||
const source = isPlainObject(overrides) ? overrides : {};
|
||||
|
||||
return {
|
||||
...base,
|
||||
...source,
|
||||
storage: {
|
||||
...base.storage,
|
||||
...(isPlainObject(source.storage) ? source.storage : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildStoredPayload({
|
||||
categories,
|
||||
policyVersion,
|
||||
jurisdiction,
|
||||
gpc,
|
||||
timestamp
|
||||
}) {
|
||||
return {
|
||||
engineVersion: ENGINE_VERSION,
|
||||
policyVersion,
|
||||
timestamp,
|
||||
categories: clone(categories),
|
||||
jurisdiction,
|
||||
gpc: Boolean(gpc)
|
||||
};
|
||||
}
|
||||
|
||||
function isExpired(payload, durationDays, currentTime = Date.now()) {
|
||||
if (!payload || !Number.isFinite(payload.timestamp)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(durationDays) || durationDays <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const durationMs = durationDays * 24 * 60 * 60 * 1000;
|
||||
const age = currentTime - payload.timestamp;
|
||||
|
||||
// A materially future timestamp is not accepted as valid persistence.
|
||||
if (age < 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return age >= durationMs;
|
||||
}
|
||||
|
||||
function getInitialState(config) {
|
||||
return {
|
||||
initialized: false,
|
||||
hasDecision: false,
|
||||
categories: createUnknownCategories(),
|
||||
policyVersion: config.policyVersion,
|
||||
jurisdiction: config.jurisdiction,
|
||||
gpc: false,
|
||||
timestamp: null,
|
||||
source: "default"
|
||||
};
|
||||
}
|
||||
|
||||
function categoriesEqual(a, b) {
|
||||
return (
|
||||
a.necessary === b.necessary &&
|
||||
a.preferences === b.preferences &&
|
||||
a.analytics === b.analytics &&
|
||||
a.marketing === b.marketing
|
||||
);
|
||||
}
|
||||
|
||||
export function createConsentEngine(options = {}) {
|
||||
const config = mergeConfig(DEFAULT_CONFIG, options);
|
||||
const events = createEventBus();
|
||||
const storage = createCookieStorage(config.storage);
|
||||
|
||||
let state = getInitialState(config);
|
||||
let initialized = false;
|
||||
|
||||
function getState() {
|
||||
return clone(state);
|
||||
}
|
||||
|
||||
function emit(name, extra = {}) {
|
||||
events.emit(name, {
|
||||
...getState(),
|
||||
...extra
|
||||
});
|
||||
}
|
||||
|
||||
function persist(categories, metadata = {}) {
|
||||
const timestamp = Date.now();
|
||||
|
||||
const payload = buildStoredPayload({
|
||||
categories,
|
||||
policyVersion: config.policyVersion,
|
||||
jurisdiction:
|
||||
metadata.jurisdiction || config.jurisdiction,
|
||||
gpc:
|
||||
typeof metadata.gpc === "boolean"
|
||||
? metadata.gpc
|
||||
: state.gpc,
|
||||
timestamp
|
||||
});
|
||||
|
||||
try {
|
||||
const saved = storage.set(payload);
|
||||
|
||||
if (!saved) {
|
||||
events.emit("storage:error", {
|
||||
operation: "set",
|
||||
payload: getState()
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
state = {
|
||||
...state,
|
||||
categories: normalizeCategories(
|
||||
payload.categories,
|
||||
createDefaultCategories()
|
||||
),
|
||||
hasDecision: isDecisionComplete(payload.categories),
|
||||
policyVersion: payload.policyVersion,
|
||||
jurisdiction: payload.jurisdiction,
|
||||
gpc: Boolean(payload.gpc),
|
||||
timestamp: payload.timestamp,
|
||||
source: metadata.source || "user"
|
||||
};
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
events.emit("storage:error", {
|
||||
operation: "set",
|
||||
error
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function removePersistedCookie(operation) {
|
||||
try {
|
||||
const removed = storage.remove();
|
||||
|
||||
if (!removed) {
|
||||
events.emit("storage:error", {
|
||||
operation
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
events.emit("storage:error", {
|
||||
operation,
|
||||
error
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadPersistedState() {
|
||||
let payload;
|
||||
|
||||
try {
|
||||
payload = storage.get();
|
||||
} catch (error) {
|
||||
events.emit("storage:error", {
|
||||
operation: "get",
|
||||
error
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isStoragePayload(payload)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidCategoriesObject(payload.categories)) {
|
||||
// Invalid persistence is not allowed to influence the live state.
|
||||
// Attempt to remove it so it cannot repeatedly poison initialization.
|
||||
removePersistedCookie("remove-invalid");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.policyVersion !== config.policyVersion) {
|
||||
removePersistedCookie("remove-policy-mismatch");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isExpired(payload, config.storage.duration)) {
|
||||
const removed = removePersistedCookie("remove-expired");
|
||||
|
||||
if (!removed) {
|
||||
// Do not pretend the stale cookie has been removed.
|
||||
// The in-memory state remains undecided for this page load.
|
||||
state = {
|
||||
...state,
|
||||
categories: createUnknownCategories(),
|
||||
hasDecision: false,
|
||||
timestamp: null,
|
||||
source: "expired"
|
||||
};
|
||||
|
||||
emit("consent:expired");
|
||||
return false;
|
||||
}
|
||||
|
||||
state = {
|
||||
...state,
|
||||
categories: createUnknownCategories(),
|
||||
hasDecision: false,
|
||||
timestamp: null,
|
||||
source: "expired"
|
||||
};
|
||||
|
||||
emit("consent:expired");
|
||||
return false;
|
||||
}
|
||||
|
||||
state = {
|
||||
...state,
|
||||
categories: normalizeCategories(
|
||||
payload.categories,
|
||||
createUnknownCategories()
|
||||
),
|
||||
hasDecision: isDecisionComplete(payload.categories),
|
||||
policyVersion: config.policyVersion,
|
||||
jurisdiction: payload.jurisdiction,
|
||||
gpc: payload.gpc,
|
||||
timestamp: payload.timestamp,
|
||||
source: "storage"
|
||||
};
|
||||
|
||||
emit("consent:loaded");
|
||||
return true;
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
if (initialized) {
|
||||
return getState();
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
loadPersistedState();
|
||||
|
||||
state = {
|
||||
...state,
|
||||
initialized: true
|
||||
};
|
||||
|
||||
emit("initialized");
|
||||
return getState();
|
||||
}
|
||||
|
||||
function ensureInitialized() {
|
||||
if (!initialized) {
|
||||
initialize();
|
||||
}
|
||||
}
|
||||
|
||||
function setConsent(changes, metadata = {}) {
|
||||
ensureInitialized();
|
||||
|
||||
if (!isPlainObject(changes)) {
|
||||
throw new TypeError("Consent changes must be an object.");
|
||||
}
|
||||
|
||||
const previous = state.categories;
|
||||
const next = mergeCategories(previous, changes);
|
||||
const changed = !categoriesEqual(previous, next);
|
||||
|
||||
if (!changed) {
|
||||
return getState();
|
||||
}
|
||||
|
||||
const saved = persist(next, metadata);
|
||||
|
||||
if (!saved) {
|
||||
return getState();
|
||||
}
|
||||
|
||||
emit("consent:changed", {
|
||||
previousCategories: previous,
|
||||
categories: next
|
||||
});
|
||||
|
||||
if (hasAcceptedAll(next)) {
|
||||
emit("consent:accepted");
|
||||
} else if (hasRejectedAll(next)) {
|
||||
emit("consent:rejected");
|
||||
}
|
||||
|
||||
return getState();
|
||||
}
|
||||
|
||||
function acceptAll() {
|
||||
ensureInitialized();
|
||||
|
||||
const previous = state.categories;
|
||||
const next = createAcceptedCategories();
|
||||
|
||||
if (categoriesEqual(previous, next)) {
|
||||
return getState();
|
||||
}
|
||||
|
||||
const saved = persist(next);
|
||||
|
||||
if (!saved) {
|
||||
return getState();
|
||||
}
|
||||
|
||||
emit("consent:changed", {
|
||||
previousCategories: previous,
|
||||
categories: next
|
||||
});
|
||||
|
||||
emit("consent:accepted");
|
||||
return getState();
|
||||
}
|
||||
|
||||
function rejectAll() {
|
||||
ensureInitialized();
|
||||
|
||||
const previous = state.categories;
|
||||
const next = createRejectedCategories();
|
||||
|
||||
if (categoriesEqual(previous, next)) {
|
||||
return getState();
|
||||
}
|
||||
|
||||
const saved = persist(next);
|
||||
|
||||
if (!saved) {
|
||||
return getState();
|
||||
}
|
||||
|
||||
emit("consent:changed", {
|
||||
previousCategories: previous,
|
||||
categories: next
|
||||
});
|
||||
|
||||
emit("consent:rejected");
|
||||
return getState();
|
||||
}
|
||||
|
||||
function revoke() {
|
||||
ensureInitialized();
|
||||
|
||||
const previous = state.categories;
|
||||
const next = createRejectedCategories();
|
||||
|
||||
if (categoriesEqual(previous, next)) {
|
||||
return getState();
|
||||
}
|
||||
|
||||
const saved = persist(next, {
|
||||
jurisdiction: state.jurisdiction,
|
||||
gpc: state.gpc,
|
||||
source: "user"
|
||||
});
|
||||
|
||||
if (!saved) {
|
||||
return getState();
|
||||
}
|
||||
|
||||
emit("consent:revoked", {
|
||||
previousCategories: previous,
|
||||
categories: next
|
||||
});
|
||||
|
||||
emit("consent:changed", {
|
||||
previousCategories: previous,
|
||||
categories: next
|
||||
});
|
||||
|
||||
return getState();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
ensureInitialized();
|
||||
|
||||
const removed = removePersistedCookie("remove-reset");
|
||||
|
||||
if (!removed) {
|
||||
// Do not create a false local reset when persisted consent may
|
||||
// still exist. This guarantees reload consistency.
|
||||
return getState();
|
||||
}
|
||||
|
||||
state = getInitialState(config);
|
||||
state.initialized = true;
|
||||
|
||||
emit("consent:reset");
|
||||
return getState();
|
||||
}
|
||||
|
||||
function setGpc(value) {
|
||||
ensureInitialized();
|
||||
|
||||
state = {
|
||||
...state,
|
||||
gpc: Boolean(value)
|
||||
};
|
||||
|
||||
return getState();
|
||||
}
|
||||
|
||||
function setJurisdiction(value) {
|
||||
ensureInitialized();
|
||||
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new TypeError(
|
||||
"jurisdiction must be a non-empty string."
|
||||
);
|
||||
}
|
||||
|
||||
state = {
|
||||
...state,
|
||||
jurisdiction: value.trim()
|
||||
};
|
||||
|
||||
return getState();
|
||||
}
|
||||
|
||||
function hasConsent(category) {
|
||||
ensureInitialized();
|
||||
|
||||
if (
|
||||
typeof category !== "string" ||
|
||||
!Object.prototype.hasOwnProperty.call(
|
||||
state.categories,
|
||||
category
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return state.categories[category] === "granted";
|
||||
}
|
||||
|
||||
function isComplete() {
|
||||
ensureInitialized();
|
||||
return state.hasDecision;
|
||||
}
|
||||
|
||||
function isInitialized() {
|
||||
return initialized;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
initialize,
|
||||
getState,
|
||||
getConsent: getState,
|
||||
setConsent,
|
||||
acceptAll,
|
||||
rejectAll,
|
||||
revoke,
|
||||
reset,
|
||||
setGpc,
|
||||
setJurisdiction,
|
||||
hasConsent,
|
||||
isComplete,
|
||||
isInitialized,
|
||||
toPermissionMap() {
|
||||
ensureInitialized();
|
||||
return toPermissionMap(state.categories);
|
||||
},
|
||||
getConfig() {
|
||||
return clone(config);
|
||||
},
|
||||
on: events.on,
|
||||
once: events.once,
|
||||
off: events.off
|
||||
});
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Cookie Consent Core
|
||||
* events.js
|
||||
*
|
||||
* Tiny dependency-free event bus.
|
||||
*/
|
||||
|
||||
export function createEventBus() {
|
||||
const listeners = new Map();
|
||||
|
||||
function on(eventName, callback) {
|
||||
if (typeof eventName !== "string" || !eventName.trim()) {
|
||||
throw new TypeError("eventName must be a non-empty string.");
|
||||
}
|
||||
|
||||
if (typeof callback !== "function") {
|
||||
throw new TypeError("callback must be a function.");
|
||||
}
|
||||
|
||||
if (!listeners.has(eventName)) {
|
||||
listeners.set(eventName, new Set());
|
||||
}
|
||||
|
||||
listeners.get(eventName).add(callback);
|
||||
return () => off(eventName, callback);
|
||||
}
|
||||
|
||||
function once(eventName, callback) {
|
||||
const unsubscribe = on(eventName, (payload) => {
|
||||
unsubscribe();
|
||||
callback(payload);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
function off(eventName, callback) {
|
||||
const callbacks = listeners.get(eventName);
|
||||
|
||||
if (!callbacks) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const removed = callbacks.delete(callback);
|
||||
|
||||
if (callbacks.size === 0) {
|
||||
listeners.delete(eventName);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
function emit(eventName, payload) {
|
||||
const callbacks = listeners.get(eventName);
|
||||
|
||||
if (!callbacks) {
|
||||
return;
|
||||
}
|
||||
|
||||
[...callbacks].forEach((callback) => {
|
||||
try {
|
||||
callback(payload);
|
||||
} catch (error) {
|
||||
queueMicrotask(() => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clear(eventName) {
|
||||
if (typeof eventName === "string") {
|
||||
listeners.delete(eventName);
|
||||
return;
|
||||
}
|
||||
|
||||
listeners.clear();
|
||||
}
|
||||
|
||||
function listenerCount(eventName) {
|
||||
return listeners.get(eventName)?.size || 0;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
on,
|
||||
once,
|
||||
off,
|
||||
emit,
|
||||
clear,
|
||||
listenerCount
|
||||
});
|
||||
}
|
||||
|
||||
export const EVENT_NAMES = Object.freeze([
|
||||
"initialized",
|
||||
"consent:loaded",
|
||||
"consent:accepted",
|
||||
"consent:rejected",
|
||||
"consent:changed",
|
||||
"consent:revoked",
|
||||
"consent:expired",
|
||||
"consent:reset",
|
||||
"storage:error"
|
||||
]);
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Cookie Consent Core
|
||||
* storage.js
|
||||
*
|
||||
* Cookie-based persistence for consent state.
|
||||
* This module deliberately contains no consent/business operations.
|
||||
*/
|
||||
|
||||
const DEFAULT_COOKIE_NAME = "cc_consent";
|
||||
const DEFAULT_DURATION_DAYS = 180;
|
||||
const DEFAULT_PATH = "/";
|
||||
const DEFAULT_SAME_SITE = "Lax";
|
||||
|
||||
function isBrowser() {
|
||||
return typeof document !== "undefined";
|
||||
}
|
||||
|
||||
function encode(value) {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function decode(value) {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildAttributes(options = {}) {
|
||||
const {
|
||||
duration,
|
||||
path = DEFAULT_PATH,
|
||||
domain,
|
||||
sameSite = DEFAULT_SAME_SITE,
|
||||
secure,
|
||||
partitioned = false
|
||||
} = options;
|
||||
|
||||
const attributes = [];
|
||||
|
||||
if (typeof duration === "number" && Number.isFinite(duration)) {
|
||||
if (duration <= 0) {
|
||||
attributes.push("Max-Age=0");
|
||||
} else {
|
||||
const maxAge = Math.floor(duration * 24 * 60 * 60);
|
||||
attributes.push(`Max-Age=${maxAge}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (path) {
|
||||
attributes.push(`Path=${path}`);
|
||||
}
|
||||
|
||||
if (domain) {
|
||||
attributes.push(`Domain=${domain}`);
|
||||
}
|
||||
|
||||
if (sameSite) {
|
||||
attributes.push(`SameSite=${sameSite}`);
|
||||
}
|
||||
|
||||
const shouldSecure =
|
||||
secure === true ||
|
||||
(secure === undefined &&
|
||||
typeof location !== "undefined" &&
|
||||
location.protocol === "https:");
|
||||
|
||||
if (shouldSecure) {
|
||||
attributes.push("Secure");
|
||||
}
|
||||
|
||||
if (partitioned) {
|
||||
attributes.push("Partitioned");
|
||||
}
|
||||
|
||||
return attributes.join("; ");
|
||||
}
|
||||
|
||||
export function createCookieStorage(options = {}) {
|
||||
const config = {
|
||||
name: options.name || DEFAULT_COOKIE_NAME,
|
||||
duration:
|
||||
typeof options.duration === "number"
|
||||
? options.duration
|
||||
: DEFAULT_DURATION_DAYS,
|
||||
path: options.path || DEFAULT_PATH,
|
||||
domain: options.domain,
|
||||
sameSite: options.sameSite || DEFAULT_SAME_SITE,
|
||||
secure: options.secure,
|
||||
partitioned: options.partitioned === true
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
name: config.name,
|
||||
|
||||
getRaw() {
|
||||
if (!isBrowser()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefix = `${encode(config.name)}=`;
|
||||
const cookies = document.cookie ? document.cookie.split("; ") : [];
|
||||
|
||||
for (const item of cookies) {
|
||||
if (item.startsWith(prefix)) {
|
||||
return decode(item.slice(prefix.length));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
get() {
|
||||
const raw = this.getRaw();
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
set(payload) {
|
||||
if (!isBrowser()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let serialized;
|
||||
|
||||
try {
|
||||
serialized = JSON.stringify(payload);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attributes = buildAttributes(config);
|
||||
|
||||
document.cookie =
|
||||
`${encode(config.name)}=${encode(serialized)}` +
|
||||
(attributes ? `; ${attributes}` : "");
|
||||
|
||||
return this.getRaw() !== null;
|
||||
},
|
||||
|
||||
remove() {
|
||||
if (!isBrowser()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attributes = buildAttributes({
|
||||
...config,
|
||||
duration: 0
|
||||
});
|
||||
|
||||
document.cookie =
|
||||
`${encode(config.name)}=` +
|
||||
(attributes ? `; ${attributes}` : "");
|
||||
|
||||
return this.getRaw() === null;
|
||||
},
|
||||
|
||||
getConfig() {
|
||||
return { ...config };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the persisted envelope before the consent engine attempts to
|
||||
* interpret its contents. Category semantics are validated separately.
|
||||
*/
|
||||
export function isStoragePayload(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof value.engineVersion !== "string" || !value.engineVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof value.policyVersion !== "string" || !value.policyVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(value.timestamp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!value.categories ||
|
||||
typeof value.categories !== "object" ||
|
||||
Array.isArray(value.categories)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof value.jurisdiction !== "string" || !value.jurisdiction) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof value.gpc !== "boolean") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const STORAGE_DEFAULTS = Object.freeze({
|
||||
name: DEFAULT_COOKIE_NAME,
|
||||
duration: DEFAULT_DURATION_DAYS,
|
||||
path: DEFAULT_PATH,
|
||||
sameSite: DEFAULT_SAME_SITE
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Universal Cookie Consent
|
||||
* integration/context.js
|
||||
*
|
||||
* Thin integration boundary between the pure Jurisdiction Engine and
|
||||
* the independent Consent Core.
|
||||
*
|
||||
* The jurisdiction result is context only. It never grants or denies
|
||||
* consent categories.
|
||||
*/
|
||||
|
||||
import {
|
||||
resolveJurisdiction,
|
||||
isValidResolution
|
||||
} from "../jurisdiction/index.js";
|
||||
|
||||
function freezeResolution(resolution) {
|
||||
return Object.freeze({
|
||||
status: resolution.status,
|
||||
jurisdiction: Object.freeze({ ...resolution.jurisdiction }),
|
||||
policy: Object.freeze({ ...resolution.policy }),
|
||||
source: resolution.source,
|
||||
confidence: resolution.confidence
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveConsentContext(input = {}) {
|
||||
const resolution = resolveJurisdiction(input);
|
||||
|
||||
if (!isValidResolution(resolution)) {
|
||||
throw new Error("Jurisdiction Engine returned an invalid resolution.");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
resolution: freezeResolution(resolution)
|
||||
});
|
||||
}
|
||||
|
||||
export function getConsentJurisdiction(input = {}) {
|
||||
return resolveConsentContext(input).resolution.jurisdiction.id;
|
||||
}
|
||||
|
||||
export function getConsentPolicy(input = {}) {
|
||||
return resolveConsentContext(input).resolution.policy.id;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Universal Cookie Consent
|
||||
* integration/index.js
|
||||
*
|
||||
* Public integration API.
|
||||
*
|
||||
* Jurisdiction resolution is performed before the Consent Core is created.
|
||||
* The resolved jurisdiction becomes Core configuration; it does not become
|
||||
* a consent decision.
|
||||
*/
|
||||
|
||||
import { createConsentEngine } from "../core/consent.js";
|
||||
import { resolveConsentContext } from "./context.js";
|
||||
|
||||
export { resolveConsentContext } from "./context.js";
|
||||
|
||||
export function createIntegratedConsentEngine({
|
||||
jurisdiction = {},
|
||||
core = {}
|
||||
} = {}) {
|
||||
const context = resolveConsentContext(jurisdiction);
|
||||
|
||||
const engine = createConsentEngine({
|
||||
...core,
|
||||
jurisdiction: context.resolution.jurisdiction.id
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
...engine,
|
||||
getJurisdictionContext() {
|
||||
return context;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Universal Cookie Consent
|
||||
* jurisdiction/constants.js
|
||||
*
|
||||
* Pure constants for jurisdiction resolution. No DOM, network, storage,
|
||||
* geolocation, GTM, or legal enforcement logic belongs here.
|
||||
*/
|
||||
|
||||
export const JURISDICTION_MODES = Object.freeze([
|
||||
"fixed",
|
||||
"auto",
|
||||
"global"
|
||||
]);
|
||||
|
||||
export const RESOLUTION_SOURCES = Object.freeze([
|
||||
"site-config",
|
||||
"server",
|
||||
"application",
|
||||
"browser",
|
||||
"fallback"
|
||||
]);
|
||||
|
||||
export const CONFIDENCE_LEVELS = Object.freeze([
|
||||
"explicit",
|
||||
"high",
|
||||
"medium",
|
||||
"low",
|
||||
"default"
|
||||
]);
|
||||
|
||||
export const RESOLUTION_STATUSES = Object.freeze([
|
||||
"resolved",
|
||||
"fallback"
|
||||
]);
|
||||
|
||||
export const DEFAULT_JURISDICTION_ID = "global";
|
||||
export const DEFAULT_POLICY_ID = "global-baseline";
|
||||
export const DEFAULT_MODE = "auto";
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Universal Cookie Consent
|
||||
* jurisdiction/index.js
|
||||
*
|
||||
* Public API for the Jurisdiction Engine.
|
||||
*/
|
||||
|
||||
export {
|
||||
JURISDICTION_MODES,
|
||||
RESOLUTION_SOURCES,
|
||||
CONFIDENCE_LEVELS,
|
||||
RESOLUTION_STATUSES,
|
||||
DEFAULT_JURISDICTION_ID,
|
||||
DEFAULT_POLICY_ID,
|
||||
DEFAULT_MODE
|
||||
} from "./constants.js";
|
||||
|
||||
export {
|
||||
JURISDICTION_CATALOG,
|
||||
getJurisdiction,
|
||||
hasJurisdiction
|
||||
} from "./jurisdiction.js";
|
||||
|
||||
export {
|
||||
POLICIES,
|
||||
getPolicy,
|
||||
hasPolicy
|
||||
} from "./policies.js";
|
||||
|
||||
export {
|
||||
resolveJurisdiction,
|
||||
getResolutionPriority,
|
||||
getSourceConfidence,
|
||||
isValidResolution
|
||||
} from "./resolver.js";
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Universal Cookie Consent
|
||||
* jurisdiction/jurisdiction.js
|
||||
*
|
||||
* Declarative jurisdiction catalog. This file maps jurisdiction identifiers
|
||||
* to policy identifiers. It does not detect visitors or enforce consent.
|
||||
*/
|
||||
|
||||
import { hasPolicy } from "./policies.js";
|
||||
|
||||
const JURISDICTIONS = {
|
||||
global: {
|
||||
id: "global",
|
||||
country: null,
|
||||
region: null,
|
||||
policy: "global-baseline"
|
||||
},
|
||||
|
||||
us: {
|
||||
id: "us",
|
||||
country: "US",
|
||||
region: null,
|
||||
policy: "us-state-privacy"
|
||||
},
|
||||
|
||||
"us-ma": {
|
||||
id: "us-ma",
|
||||
country: "US",
|
||||
region: "MA",
|
||||
policy: "us-state-privacy"
|
||||
},
|
||||
|
||||
"us-ca": {
|
||||
id: "us-ca",
|
||||
country: "US",
|
||||
region: "CA",
|
||||
policy: "us-california-privacy"
|
||||
},
|
||||
|
||||
eu: {
|
||||
id: "eu",
|
||||
country: "EU",
|
||||
region: null,
|
||||
policy: "eu-privacy"
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of Object.values(JURISDICTIONS)) {
|
||||
if (!hasPolicy(entry.policy)) {
|
||||
throw new Error(`Jurisdiction references unknown policy: ${entry.policy}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const JURISDICTION_CATALOG = Object.freeze(
|
||||
Object.fromEntries(
|
||||
Object.entries(JURISDICTIONS).map(([id, value]) => [
|
||||
id,
|
||||
Object.freeze({ ...value })
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
export function getJurisdiction(id) {
|
||||
if (typeof id !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JURISDICTION_CATALOG[id] || null;
|
||||
}
|
||||
|
||||
export function hasJurisdiction(id) {
|
||||
return Boolean(getJurisdiction(id));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Universal Cookie Consent
|
||||
* jurisdiction/policies.js
|
||||
*
|
||||
* Declarative policy catalog.
|
||||
*
|
||||
* IMPORTANT:
|
||||
* These entries identify policy families only. They are NOT legal advice and
|
||||
* deliberately do not encode detailed legal requirements yet. Those rules
|
||||
* will be added after current-source legal research and policy review.
|
||||
*/
|
||||
|
||||
const freeze = (value) => Object.freeze(value);
|
||||
|
||||
export const POLICIES = Object.freeze({
|
||||
"global-baseline": freeze({
|
||||
id: "global-baseline",
|
||||
family: "global",
|
||||
version: "1.0",
|
||||
description: "Conservative global baseline pending jurisdiction-specific policy."
|
||||
}),
|
||||
|
||||
"us-state-privacy": freeze({
|
||||
id: "us-state-privacy",
|
||||
family: "us-state",
|
||||
version: "1.0",
|
||||
description: "Policy family for U.S. state privacy regimes."
|
||||
}),
|
||||
|
||||
"us-california-privacy": freeze({
|
||||
id: "us-california-privacy",
|
||||
family: "us-state",
|
||||
version: "1.0",
|
||||
description: "Policy family reserved for California-specific requirements."
|
||||
}),
|
||||
|
||||
"eu-privacy": freeze({
|
||||
id: "eu-privacy",
|
||||
family: "eu",
|
||||
version: "1.0",
|
||||
description: "Policy family reserved for applicable European privacy requirements."
|
||||
})
|
||||
});
|
||||
|
||||
export function getPolicy(policyId) {
|
||||
if (typeof policyId !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return POLICIES[policyId] || null;
|
||||
}
|
||||
|
||||
export function hasPolicy(policyId) {
|
||||
return Boolean(getPolicy(policyId));
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Universal Cookie Consent
|
||||
* jurisdiction/resolver.js
|
||||
*
|
||||
* Pure deterministic jurisdiction resolver.
|
||||
*
|
||||
* It consumes explicitly supplied signals. It never performs network calls,
|
||||
* reads IP addresses, accesses browser location, or guesses silently.
|
||||
*/
|
||||
|
||||
import {
|
||||
JURISDICTION_MODES,
|
||||
RESOLUTION_SOURCES,
|
||||
CONFIDENCE_LEVELS,
|
||||
DEFAULT_JURISDICTION_ID,
|
||||
DEFAULT_POLICY_ID,
|
||||
DEFAULT_MODE
|
||||
} from "./constants.js";
|
||||
import { getJurisdiction, hasJurisdiction } from "./jurisdiction.js";
|
||||
import { getPolicy, hasPolicy } from "./policies.js";
|
||||
|
||||
const SOURCE_CONFIDENCE = Object.freeze({
|
||||
"site-config": "explicit",
|
||||
server: "high",
|
||||
application: "medium",
|
||||
browser: "low",
|
||||
fallback: "default"
|
||||
});
|
||||
|
||||
const SOURCE_PRIORITY = Object.freeze({
|
||||
"site-config": 500,
|
||||
server: 400,
|
||||
application: 300,
|
||||
browser: 200,
|
||||
fallback: 100
|
||||
});
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function assertMode(mode) {
|
||||
if (!JURISDICTION_MODES.includes(mode)) {
|
||||
throw new TypeError(
|
||||
`Invalid jurisdiction mode. Expected: ${JURISDICTION_MODES.join(", ")}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEntry(id, source, status = "resolved") {
|
||||
const entry = getJurisdiction(id);
|
||||
|
||||
if (!entry) {
|
||||
throw new Error(`Unknown jurisdiction: ${id}`);
|
||||
}
|
||||
|
||||
const policy = getPolicy(entry.policy);
|
||||
|
||||
if (!policy) {
|
||||
throw new Error(`Unknown policy: ${entry.policy}`);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
status,
|
||||
jurisdiction: Object.freeze({
|
||||
id: entry.id,
|
||||
country: entry.country,
|
||||
region: entry.region
|
||||
}),
|
||||
policy: Object.freeze({
|
||||
id: policy.id,
|
||||
family: policy.family,
|
||||
version: policy.version
|
||||
}),
|
||||
source,
|
||||
confidence: SOURCE_CONFIDENCE[source]
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSignal(signal, fallbackSource) {
|
||||
if (!isPlainObject(signal)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = normalizeString(signal.jurisdiction || signal.id);
|
||||
if (!id || !hasJurisdiction(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = normalizeString(signal.source) || fallbackSource;
|
||||
if (!RESOLUTION_SOURCES.includes(source)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
priority: SOURCE_PRIORITY[source]
|
||||
};
|
||||
}
|
||||
|
||||
function chooseBestSignal(signals) {
|
||||
const candidates = [];
|
||||
|
||||
for (const signal of signals) {
|
||||
const normalized = normalizeSignal(signal, "browser");
|
||||
if (normalized) {
|
||||
candidates.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidates.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.priority - a.priority);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function resolveAuto(input) {
|
||||
const site = isPlainObject(input.site) ? input.site : {};
|
||||
const request = isPlainObject(input.request) ? input.request : {};
|
||||
const visitor = isPlainObject(input.visitor) ? input.visitor : {};
|
||||
|
||||
const signal = chooseBestSignal([
|
||||
{ jurisdiction: request.jurisdiction, source: request.source || "server" },
|
||||
{ jurisdiction: request.regionJurisdiction, source: request.regionSource || "server" },
|
||||
{ jurisdiction: visitor.jurisdiction, source: visitor.source || "browser" },
|
||||
{ jurisdiction: visitor.regionJurisdiction, source: visitor.regionSource || "browser" },
|
||||
{ jurisdiction: site.jurisdiction, source: site.source || "application" }
|
||||
]);
|
||||
|
||||
if (signal) {
|
||||
return resolveEntry(signal.id, signal.source);
|
||||
}
|
||||
|
||||
return resolveEntry(DEFAULT_JURISDICTION_ID, "fallback", "fallback");
|
||||
}
|
||||
|
||||
export function resolveJurisdiction(input = {}) {
|
||||
if (!isPlainObject(input)) {
|
||||
throw new TypeError("Jurisdiction input must be an object.");
|
||||
}
|
||||
|
||||
const site = isPlainObject(input.site) ? input.site : {};
|
||||
const mode = normalizeString(input.mode || site.mode || DEFAULT_MODE) || DEFAULT_MODE;
|
||||
|
||||
assertMode(mode);
|
||||
|
||||
if (mode === "global") {
|
||||
return resolveEntry(DEFAULT_JURISDICTION_ID, "site-config");
|
||||
}
|
||||
|
||||
if (mode === "fixed") {
|
||||
const id = normalizeString(input.jurisdiction || site.jurisdiction);
|
||||
|
||||
if (!id) {
|
||||
throw new TypeError(
|
||||
"A jurisdiction is required when mode is \"fixed\"."
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasJurisdiction(id)) {
|
||||
throw new RangeError(`Unknown jurisdiction: ${id}`);
|
||||
}
|
||||
|
||||
return resolveEntry(id, "site-config");
|
||||
}
|
||||
|
||||
return resolveAuto(input);
|
||||
}
|
||||
|
||||
export function getResolutionPriority(source) {
|
||||
return SOURCE_PRIORITY[source] || 0;
|
||||
}
|
||||
|
||||
export function getSourceConfidence(source) {
|
||||
return SOURCE_CONFIDENCE[source] || null;
|
||||
}
|
||||
|
||||
export function isValidResolution(result) {
|
||||
if (!isPlainObject(result)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(result, "status")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(result, "jurisdiction")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(result, "policy")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(result.status === "resolved" || result.status === "fallback") &&
|
||||
typeof result.jurisdiction.id === "string" &&
|
||||
typeof result.policy.id === "string" &&
|
||||
RESOLUTION_SOURCES.includes(result.source) &&
|
||||
CONFIDENCE_LEVELS.includes(result.confidence) &&
|
||||
hasJurisdiction(result.jurisdiction.id) &&
|
||||
hasPolicy(result.policy.id)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"module"}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createConsentEngine,
|
||||
ENGINE_VERSION
|
||||
} from './core/consent.js';
|
||||
import { isStoragePayload } from './core/storage.js';
|
||||
import { isValidCategoriesObject } from './core/categories.js';
|
||||
|
||||
let jar = new Map();
|
||||
let blockWrites = false;
|
||||
|
||||
globalThis.location = { protocol: 'http:' };
|
||||
globalThis.document = {};
|
||||
Object.defineProperty(globalThis.document, 'cookie', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return [...jar.entries()].map(([k,v]) => `${k}=${v}`).join('; ');
|
||||
},
|
||||
set(value) {
|
||||
if (blockWrites) return;
|
||||
const parts = value.split(';').map(x => x.trim());
|
||||
const [name, raw = ''] = parts[0].split('=');
|
||||
const maxAge = parts.find(x => /^Max-Age=/i.test(x));
|
||||
if (maxAge && maxAge.toLowerCase() === 'max-age=0') {
|
||||
jar.delete(name);
|
||||
return;
|
||||
}
|
||||
jar.set(name, raw);
|
||||
}
|
||||
});
|
||||
|
||||
function clearCookie() { jar.clear(); blockWrites = false; }
|
||||
function make() {
|
||||
return createConsentEngine({
|
||||
policyVersion: '2026-01',
|
||||
jurisdiction: 'global',
|
||||
storage: { name: 'cc_consent', duration: 180, path: '/', sameSite: 'Lax' }
|
||||
});
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(`PASS ${name}`); }
|
||||
catch (e) { console.error(`FAIL ${name}\n`, e); process.exitCode = 1; }
|
||||
}
|
||||
|
||||
clearCookie();
|
||||
|
||||
test('default state is undecided', () => {
|
||||
const e = make(); e.initialize(); const s = e.getState();
|
||||
assert.equal(s.categories.necessary, 'granted');
|
||||
assert.equal(s.categories.analytics, 'unknown');
|
||||
assert.equal(s.hasDecision, false);
|
||||
});
|
||||
|
||||
test('accept all', () => {
|
||||
clearCookie();
|
||||
const e = make(); e.initialize(); let changed = 0, accepted = 0;
|
||||
e.on('consent:changed', () => changed++); e.on('consent:accepted', () => accepted++);
|
||||
e.acceptAll();
|
||||
assert.equal(e.getState().hasDecision, true);
|
||||
assert.equal(e.getState().categories.analytics, 'granted');
|
||||
assert.equal(changed, 1); assert.equal(accepted, 1);
|
||||
});
|
||||
|
||||
test('accept all is idempotent', () => {
|
||||
clearCookie();
|
||||
const e = make(); e.initialize(); let events = 0; e.on('consent:changed', () => events++);
|
||||
e.acceptAll(); const before = e.getState().timestamp; e.acceptAll();
|
||||
assert.equal(events, 1); assert.equal(e.getState().timestamp, before);
|
||||
});
|
||||
|
||||
test('persistence loads from storage', () => {
|
||||
clearCookie();
|
||||
const e = make(); e.initialize(); e.rejectAll();
|
||||
const fresh = make(); fresh.initialize(); const s = fresh.getState();
|
||||
assert.equal(s.source, 'storage'); assert.equal(s.categories.analytics, 'denied');
|
||||
});
|
||||
|
||||
test('reject all is idempotent', () => {
|
||||
clearCookie(); const e = make(); e.initialize(); let events = 0; e.on('consent:changed', () => events++);
|
||||
e.rejectAll(); e.rejectAll(); assert.equal(events, 1);
|
||||
});
|
||||
|
||||
test('selective consent', () => {
|
||||
clearCookie(); const e = make(); e.initialize(); e.setConsent({ analytics: 'granted', marketing: 'denied', preferences: 'denied' });
|
||||
assert.equal(e.getState().categories.analytics, 'granted'); assert.equal(e.isComplete(), true);
|
||||
});
|
||||
|
||||
test('invalid consent value throws', () => {
|
||||
clearCookie(); const e = make(); e.initialize(); assert.throws(() => e.setConsent({ analytics: 'banana' }), TypeError);
|
||||
});
|
||||
|
||||
test('necessary cannot be denied', () => {
|
||||
clearCookie(); const e = make(); e.initialize();
|
||||
assert.throws(() => e.setConsent({ necessary: 'denied', analytics: 'granted' }), TypeError);
|
||||
assert.equal(e.getState().categories.necessary, 'granted');
|
||||
assert.equal(e.getState().categories.analytics, 'unknown');
|
||||
});
|
||||
|
||||
test('revoke is idempotent', () => {
|
||||
clearCookie(); const e = make(); e.initialize(); e.acceptAll(); let revoked = 0; e.on('consent:revoked', () => revoked++);
|
||||
e.revoke(); e.revoke(); assert.equal(revoked, 1); assert.equal(e.getState().categories.analytics, 'denied');
|
||||
});
|
||||
|
||||
test('reset removes persistence', () => {
|
||||
clearCookie(); const e = make(); e.initialize(); e.acceptAll(); e.reset();
|
||||
assert.equal(e.isComplete(), false); assert.equal(document.cookie, '');
|
||||
const fresh = make(); fresh.initialize(); assert.equal(fresh.isComplete(), false); assert.equal(fresh.getState().source, 'default');
|
||||
});
|
||||
|
||||
test('reset does not lie when storage removal fails', () => {
|
||||
clearCookie(); const e = make(); e.initialize(); e.acceptAll(); blockWrites = true; let reset = 0, errors = 0;
|
||||
e.on('consent:reset', () => reset++); e.on('storage:error', () => errors++);
|
||||
const before = e.getState(); e.reset();
|
||||
assert.equal(reset, 0); assert.equal(errors, 1); assert.equal(e.getState().categories.analytics, before.categories.analytics);
|
||||
});
|
||||
|
||||
test('strict stored envelope validation', () => {
|
||||
assert.equal(isStoragePayload({}), false);
|
||||
assert.equal(isStoragePayload({ engineVersion: ENGINE_VERSION, policyVersion: '2026-01', timestamp: Date.now(), categories: {}, jurisdiction: 'global', gpc: false }), true);
|
||||
});
|
||||
|
||||
test('strict category validation', () => {
|
||||
assert.equal(isValidCategoriesObject({ necessary:'granted', preferences:'unknown', analytics:'granted', marketing:'denied' }), true);
|
||||
assert.equal(isValidCategoriesObject({ necessary:'denied', preferences:'unknown', analytics:'granted', marketing:'denied' }), false);
|
||||
assert.equal(isValidCategoriesObject({ necessary:'granted', preferences:'banana', analytics:'granted', marketing:'denied' }), false);
|
||||
});
|
||||
|
||||
test('initialize is idempotent', () => {
|
||||
clearCookie(); const e = make(); let initialized = 0; e.on('initialized', () => initialized++); e.initialize(); e.initialize(); assert.equal(initialized, 1);
|
||||
});
|
||||
|
||||
test('policy mismatch removes valid cookie', () => {
|
||||
clearCookie(); const e = make(); e.initialize(); e.acceptAll();
|
||||
const fresh = createConsentEngine({ policyVersion:'2027-01', jurisdiction:'global', storage:{ name:'cc_consent', duration:180, path:'/', sameSite:'Lax' } });
|
||||
fresh.initialize(); assert.equal(fresh.isComplete(), false); assert.equal(document.cookie, '');
|
||||
});
|
||||
|
||||
test('expired cookie is rejected', () => {
|
||||
clearCookie(); const e = make(); e.initialize(); e.acceptAll();
|
||||
const raw = decodeURIComponent(document.cookie.split('=').slice(1).join('=')); const payload = JSON.parse(raw); payload.timestamp = Date.now() - 181*24*60*60*1000;
|
||||
document.cookie = `cc_consent=${encodeURIComponent(JSON.stringify(payload))}; Path=/; SameSite=Lax`;
|
||||
const fresh = make(); let expired = 0; fresh.on('consent:expired', () => expired++); fresh.initialize();
|
||||
assert.equal(expired, 1); assert.equal(fresh.isComplete(), false); assert.equal(document.cookie, '');
|
||||
});
|
||||
|
||||
|
||||
test('necessary denial is explicitly rejected', () => {
|
||||
clearCookie(); const e = make(); e.initialize();
|
||||
assert.throws(() => e.setConsent({ necessary: 'denied' }), TypeError);
|
||||
assert.equal(e.getState().categories.necessary, 'granted');
|
||||
});
|
||||
|
||||
test('invalid persisted category payload is ignored and removed', () => {
|
||||
clearCookie();
|
||||
const bad = {
|
||||
engineVersion: ENGINE_VERSION,
|
||||
policyVersion: '2026-01',
|
||||
timestamp: Date.now(),
|
||||
categories: { necessary: 'granted', preferences: 'banana', analytics: 'granted', marketing: 'denied' },
|
||||
jurisdiction: 'global',
|
||||
gpc: false
|
||||
};
|
||||
document.cookie = `cc_consent=${encodeURIComponent(JSON.stringify(bad))}; Path=/; SameSite=Lax`;
|
||||
const e = make(); e.initialize();
|
||||
assert.equal(e.isComplete(), false); assert.equal(document.cookie, '');
|
||||
});
|
||||
|
||||
test('future timestamp is rejected', () => {
|
||||
clearCookie(); const e = make(); e.initialize(); e.acceptAll();
|
||||
const raw = decodeURIComponent(document.cookie.split('=').slice(1).join('='));
|
||||
const payload = JSON.parse(raw); payload.timestamp = Date.now() + 60 * 60 * 1000;
|
||||
document.cookie = `cc_consent=${encodeURIComponent(JSON.stringify(payload))}; Path=/; SameSite=Lax`;
|
||||
const fresh = make(); fresh.initialize();
|
||||
assert.equal(fresh.isComplete(), false); assert.equal(document.cookie, '');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed.`);
|
||||
if (process.exitCode) process.exit(1);
|
||||
@@ -0,0 +1,136 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cookie Consent Audit Lab</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 40px auto; padding: 0 20px; }
|
||||
button { padding: 10px 16px; cursor: pointer; }
|
||||
#status { margin: 20px 0; font-size: 1.2rem; font-weight: 700; }
|
||||
pre { background: #f5f5f5; padding: 16px; overflow: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Cookie Consent Audit Lab</h1>
|
||||
<p>Browser audit for Core + Jurisdiction + Integration invariants.</p>
|
||||
<button id="run">Run audit</button>
|
||||
<div id="status">Not run</div>
|
||||
<pre id="log"></pre>
|
||||
|
||||
<script type="module">
|
||||
import { createIntegratedConsentEngine } from "../../integration/index.js";
|
||||
|
||||
const status = document.getElementById("status");
|
||||
const log = document.getElementById("log");
|
||||
const button = document.getElementById("run");
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function clearConsentCookie() {
|
||||
document.cookie = "ucc_consent=; Max-Age=0; Path=/; SameSite=Lax";
|
||||
}
|
||||
|
||||
async function test(name, fn, output) {
|
||||
try {
|
||||
await fn();
|
||||
output.push(`PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
output.push(`FAIL ${name} — ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAudit() {
|
||||
clearConsentCookie();
|
||||
const output = [];
|
||||
let passed = 0;
|
||||
|
||||
if (await test("dynamic jurisdiction is reflected in Core state", () => {
|
||||
const consent = createIntegratedConsentEngine({
|
||||
jurisdiction: { mode: "fixed", jurisdiction: "global" }
|
||||
});
|
||||
consent.initialize();
|
||||
assert(consent.setJurisdiction("us-ma").jurisdiction === "us-ma", "jurisdiction mismatch");
|
||||
}, output)) passed++;
|
||||
|
||||
if (await test("GPC state is represented without granting optional consent", () => {
|
||||
const consent = createIntegratedConsentEngine({
|
||||
jurisdiction: { mode: "fixed", jurisdiction: "us-ma" }
|
||||
});
|
||||
consent.initialize();
|
||||
const state = consent.setGpc(true);
|
||||
assert(state.gpc === true, "GPC was not recorded");
|
||||
assert(state.hasDecision === false, "GPC unexpectedly created a decision");
|
||||
assert(state.categories.analytics === "unknown", "analytics was granted unexpectedly");
|
||||
}, output)) passed++;
|
||||
|
||||
if (await test("acceptance persists resolved jurisdiction", () => {
|
||||
clearConsentCookie();
|
||||
const consent = createIntegratedConsentEngine({
|
||||
jurisdiction: { mode: "fixed", jurisdiction: "us-ma" }
|
||||
});
|
||||
consent.initialize();
|
||||
const state = consent.acceptAll();
|
||||
assert(state.hasDecision === true, "decision was not saved");
|
||||
assert(state.jurisdiction === "us-ma", "wrong persisted jurisdiction");
|
||||
}, output)) passed++;
|
||||
|
||||
if (await test("reload recovers the persisted decision", () => {
|
||||
const consent = createIntegratedConsentEngine({
|
||||
jurisdiction: { mode: "fixed", jurisdiction: "us-ma" }
|
||||
});
|
||||
consent.initialize();
|
||||
const state = consent.getState();
|
||||
assert(state.hasDecision === true, "decision did not survive reinitialization");
|
||||
assert(state.jurisdiction === "us-ma", "jurisdiction did not survive reinitialization");
|
||||
}, output)) passed++;
|
||||
|
||||
if (await test("reset removes the persisted decision", () => {
|
||||
const consent = createIntegratedConsentEngine({
|
||||
jurisdiction: { mode: "fixed", jurisdiction: "us-ma" }
|
||||
});
|
||||
consent.initialize();
|
||||
const state = consent.reset();
|
||||
assert(state.hasDecision === false, "reset retained decision");
|
||||
assert(state.categories.analytics === "unknown", "analytics was not reset");
|
||||
}, output)) passed++;
|
||||
|
||||
if (await test("event sequence reports acceptance once", () => {
|
||||
clearConsentCookie();
|
||||
const consent = createIntegratedConsentEngine({
|
||||
jurisdiction: { mode: "fixed", jurisdiction: "us-ma" }
|
||||
});
|
||||
const events = [];
|
||||
consent.on("consent:changed", () => events.push("changed"));
|
||||
consent.on("consent:accepted", () => events.push("accepted"));
|
||||
consent.initialize();
|
||||
consent.acceptAll();
|
||||
assert(events.join(",") === "changed,accepted", "unexpected event sequence: " + events.join(","));
|
||||
}, output)) passed++;
|
||||
|
||||
if (await test("returned state cannot mutate Core internals", () => {
|
||||
const consent = createIntegratedConsentEngine({
|
||||
jurisdiction: { mode: "fixed", jurisdiction: "us-ma" }
|
||||
});
|
||||
consent.initialize();
|
||||
const state = consent.getState();
|
||||
state.categories.analytics = "granted";
|
||||
state.jurisdiction = "changed";
|
||||
const current = consent.getState();
|
||||
assert(current.categories.analytics === "unknown", "categories leaked");
|
||||
assert(current.jurisdiction === "us-ma", "jurisdiction leaked");
|
||||
}, output)) passed++;
|
||||
|
||||
log.textContent = output.join("\n");
|
||||
status.textContent = `${passed}/7 audit tests passed`;
|
||||
clearConsentCookie();
|
||||
}
|
||||
|
||||
button.addEventListener("click", runAudit);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,256 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createIntegratedConsentEngine } from "../../integration/index.js";
|
||||
|
||||
// This suite exercises real persistence (acceptAll/revoke/reset expected to
|
||||
// take effect), unlike tests/integration/integration-test.mjs which
|
||||
// deliberately asserts the no-op behavior of a bare Node environment.
|
||||
// A document.cookie mock is required here for the same reason it is
|
||||
// required in test-core.mjs.
|
||||
let jar = new Map();
|
||||
|
||||
globalThis.location = { protocol: "http:" };
|
||||
globalThis.document = {};
|
||||
Object.defineProperty(globalThis.document, "cookie", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
|
||||
},
|
||||
set(value) {
|
||||
const parts = value.split(";").map((x) => x.trim());
|
||||
const [name, raw = ""] = parts[0].split("=");
|
||||
const maxAge = parts.find((x) => /^Max-Age=/i.test(x));
|
||||
if (maxAge && maxAge.toLowerCase() === "max-age=0") {
|
||||
jar.delete(name);
|
||||
return;
|
||||
}
|
||||
jar.set(name, raw);
|
||||
}
|
||||
});
|
||||
|
||||
let passed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
// Each test starts from a clean cookie jar so persisted consent from
|
||||
// one test cannot leak into the next (they share default jurisdiction
|
||||
// and policyVersion, so a stale cookie would otherwise be reloaded).
|
||||
jar.clear();
|
||||
|
||||
try {
|
||||
fn();
|
||||
console.log(`PASS ${name}`);
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function createEngine(jurisdiction = "us-ma") {
|
||||
return createIntegratedConsentEngine({
|
||||
jurisdiction: {
|
||||
mode: "fixed",
|
||||
jurisdiction
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("dynamic jurisdiction is reflected in Core state", () => {
|
||||
const consent = createEngine("global");
|
||||
consent.initialize();
|
||||
const state = consent.setJurisdiction("us-ma");
|
||||
|
||||
assert.equal(state.jurisdiction, "us-ma");
|
||||
});
|
||||
|
||||
test("GPC state is represented without granting optional consent", () => {
|
||||
const consent = createEngine();
|
||||
consent.initialize();
|
||||
const state = consent.setGpc(true);
|
||||
|
||||
assert.equal(state.gpc, true);
|
||||
assert.equal(state.hasDecision, false);
|
||||
assert.equal(state.categories.preferences, "unknown");
|
||||
assert.equal(state.categories.analytics, "unknown");
|
||||
assert.equal(state.categories.marketing, "unknown");
|
||||
});
|
||||
|
||||
test("revoke produces the Core-defined denied state and is idempotent", () => {
|
||||
const consent = createEngine();
|
||||
consent.initialize();
|
||||
|
||||
consent.acceptAll();
|
||||
const accepted = consent.getState();
|
||||
assert.equal(accepted.hasDecision, true);
|
||||
|
||||
let revokedEvents = 0;
|
||||
consent.on("consent:revoked", () => revokedEvents++);
|
||||
|
||||
const revoked = consent.revoke();
|
||||
const secondRevoke = consent.revoke();
|
||||
|
||||
assert.equal(revoked.categories.necessary, "granted");
|
||||
assert.equal(revoked.categories.preferences, "denied");
|
||||
assert.equal(revoked.categories.analytics, "denied");
|
||||
assert.equal(revoked.categories.marketing, "denied");
|
||||
assert.equal(secondRevoke.categories.analytics, "denied");
|
||||
assert.equal(revokedEvents, 1);
|
||||
});
|
||||
|
||||
test("reset returns Core to an undecided state", () => {
|
||||
const consent = createEngine();
|
||||
consent.initialize();
|
||||
consent.acceptAll();
|
||||
|
||||
const reset = consent.reset();
|
||||
|
||||
assert.equal(reset.hasDecision, false);
|
||||
assert.equal(reset.categories.necessary, "granted");
|
||||
assert.equal(reset.categories.preferences, "unknown");
|
||||
assert.equal(reset.categories.analytics, "unknown");
|
||||
assert.equal(reset.categories.marketing, "unknown");
|
||||
});
|
||||
|
||||
test("policy version is part of the Core state", () => {
|
||||
const consent = createEngine();
|
||||
const config = consent.getConfig();
|
||||
|
||||
assert.equal(typeof config.policyVersion, "string");
|
||||
assert.ok(config.policyVersion.length > 0);
|
||||
});
|
||||
|
||||
test("persistence preserves the resolved jurisdiction after acceptance", () => {
|
||||
const consent = createEngine("us-ma");
|
||||
consent.initialize();
|
||||
|
||||
const saved = consent.acceptAll();
|
||||
|
||||
assert.equal(saved.hasDecision, true);
|
||||
assert.equal(saved.jurisdiction, "us-ma");
|
||||
});
|
||||
|
||||
test("event sequence reports the consent transition", () => {
|
||||
const consent = createEngine();
|
||||
const events = [];
|
||||
|
||||
consent.on("consent:changed", () => events.push("changed"));
|
||||
consent.on("consent:accepted", () => events.push("accepted"));
|
||||
|
||||
consent.initialize();
|
||||
consent.acceptAll();
|
||||
|
||||
assert.deepEqual(events, ["changed", "accepted"]);
|
||||
});
|
||||
|
||||
test("Core state remains isolated from returned object mutation", () => {
|
||||
const consent = createEngine();
|
||||
consent.initialize();
|
||||
|
||||
const state = consent.getState();
|
||||
state.categories.analytics = "granted";
|
||||
state.jurisdiction = "changed";
|
||||
|
||||
const current = consent.getState();
|
||||
|
||||
assert.equal(current.categories.analytics, "unknown");
|
||||
assert.equal(current.jurisdiction, "us-ma");
|
||||
});
|
||||
|
||||
|
||||
test("separate Core instances do not share in-memory state", () => {
|
||||
const first = createEngine("us-ma");
|
||||
const second = createEngine("eu");
|
||||
|
||||
first.initialize();
|
||||
second.initialize();
|
||||
|
||||
first.setConsent({ analytics: "granted" });
|
||||
|
||||
assert.equal(first.getState().categories.analytics, "granted");
|
||||
assert.equal(second.getState().categories.analytics, "unknown");
|
||||
assert.equal(first.getState().jurisdiction, "us-ma");
|
||||
assert.equal(second.getState().jurisdiction, "eu");
|
||||
});
|
||||
|
||||
test("partial consent remains undecided until all optional categories are resolved", () => {
|
||||
const consent = createEngine();
|
||||
consent.initialize();
|
||||
|
||||
const state = consent.setConsent({ analytics: "granted" });
|
||||
|
||||
assert.equal(state.categories.analytics, "granted");
|
||||
assert.equal(state.categories.preferences, "unknown");
|
||||
assert.equal(state.categories.marketing, "unknown");
|
||||
assert.equal(state.hasDecision, false);
|
||||
assert.equal(consent.isComplete(), false);
|
||||
});
|
||||
|
||||
test("necessary remains granted through valid consent changes", () => {
|
||||
const consent = createEngine();
|
||||
consent.initialize();
|
||||
|
||||
const state = consent.setConsent({
|
||||
preferences: "denied",
|
||||
analytics: "granted",
|
||||
marketing: "denied"
|
||||
});
|
||||
|
||||
assert.equal(state.categories.necessary, "granted");
|
||||
assert.equal(state.categories.preferences, "denied");
|
||||
assert.equal(state.categories.analytics, "granted");
|
||||
assert.equal(state.categories.marketing, "denied");
|
||||
});
|
||||
|
||||
test("acceptAll is idempotent through the integration boundary", () => {
|
||||
const consent = createEngine();
|
||||
consent.initialize();
|
||||
|
||||
let changed = 0;
|
||||
let accepted = 0;
|
||||
|
||||
consent.on("consent:changed", () => changed++);
|
||||
consent.on("consent:accepted", () => accepted++);
|
||||
|
||||
consent.acceptAll();
|
||||
const first = consent.getState();
|
||||
consent.acceptAll();
|
||||
const second = consent.getState();
|
||||
|
||||
assert.equal(first.hasDecision, true);
|
||||
assert.equal(second.hasDecision, true);
|
||||
assert.equal(changed, 1);
|
||||
assert.equal(accepted, 1);
|
||||
assert.equal(second.timestamp, first.timestamp);
|
||||
});
|
||||
|
||||
test("reset is idempotent through the integration boundary", () => {
|
||||
const consent = createEngine();
|
||||
consent.initialize();
|
||||
consent.acceptAll();
|
||||
|
||||
let reset = 0;
|
||||
consent.on("consent:reset", () => reset++);
|
||||
|
||||
consent.reset();
|
||||
const first = consent.getState();
|
||||
consent.reset();
|
||||
const second = consent.getState();
|
||||
|
||||
assert.equal(first.hasDecision, false);
|
||||
assert.equal(second.hasDecision, false);
|
||||
assert.equal(second.categories.analytics, "unknown");
|
||||
});
|
||||
|
||||
test("GPC never grants optional categories through the integration boundary", () => {
|
||||
const consent = createEngine();
|
||||
consent.initialize();
|
||||
|
||||
const state = consent.setGpc(true);
|
||||
|
||||
assert.equal(state.gpc, true);
|
||||
assert.equal(state.categories.preferences, "unknown");
|
||||
assert.equal(state.categories.analytics, "unknown");
|
||||
assert.equal(state.categories.marketing, "unknown");
|
||||
assert.equal(state.hasDecision, false);
|
||||
});
|
||||
|
||||
console.log(`${passed} audit tests passed.`);
|
||||
@@ -0,0 +1,173 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cookie Consent Integration Test Lab</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 900px;
|
||||
margin: 40px auto;
|
||||
padding: 0 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
button {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#summary {
|
||||
font-weight: 700;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.pass { color: #176b2c; }
|
||||
.fail { color: #a11; }
|
||||
pre {
|
||||
background: #f5f5f5;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Cookie Consent Integration Test Lab</h1>
|
||||
<p>Browser regression suite for the Jurisdiction Engine + Consent Core boundary.</p>
|
||||
|
||||
<button id="run">Run tests</button>
|
||||
<div id="summary">Not run.</div>
|
||||
<pre id="output"></pre>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
resolveConsentContext,
|
||||
createIntegratedConsentEngine
|
||||
} from "../../integration/index.js";
|
||||
|
||||
const output = document.getElementById("output");
|
||||
const summary = document.getElementById("summary");
|
||||
const runButton = document.getElementById("run");
|
||||
|
||||
function equal(actual, expected) {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected ${expected}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
function deepEqual(actual, expected) {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error("Values are not deeply equal.");
|
||||
}
|
||||
}
|
||||
|
||||
function throws(fn, pattern) {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
if (pattern.test(error.message)) return;
|
||||
throw error;
|
||||
}
|
||||
throw new Error("Expected function to throw.");
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
output.textContent += `PASS: ${name}\n`;
|
||||
return true;
|
||||
} catch (error) {
|
||||
output.textContent += `FAIL: ${name}\n${error.stack || error}\n`;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
runButton.addEventListener("click", () => {
|
||||
output.textContent = "";
|
||||
let passed = 0;
|
||||
const total = 9;
|
||||
|
||||
if (test("fixed us-ma resolves into Core context", () => {
|
||||
const context = resolveConsentContext({
|
||||
mode: "fixed", jurisdiction: "us-ma"
|
||||
});
|
||||
equal(context.resolution.jurisdiction.id, "us-ma");
|
||||
equal(context.resolution.policy.id, "us-state-privacy");
|
||||
})) passed++;
|
||||
|
||||
if (test("fixed eu resolves into Core context", () => {
|
||||
const context = resolveConsentContext({
|
||||
mode: "fixed", jurisdiction: "eu"
|
||||
});
|
||||
equal(context.resolution.jurisdiction.id, "eu");
|
||||
equal(context.resolution.policy.id, "eu-privacy");
|
||||
})) passed++;
|
||||
|
||||
if (test("global resolves into global baseline", () => {
|
||||
const context = resolveConsentContext({ mode: "global" });
|
||||
equal(context.resolution.jurisdiction.id, "global");
|
||||
equal(context.resolution.policy.id, "global-baseline");
|
||||
})) passed++;
|
||||
|
||||
if (test("integration configures Core jurisdiction", () => {
|
||||
const consent = createIntegratedConsentEngine({
|
||||
jurisdiction: { mode: "fixed", jurisdiction: "us-ma" }
|
||||
});
|
||||
const state = consent.initialize();
|
||||
equal(state.jurisdiction, "us-ma");
|
||||
equal(state.hasDecision, false);
|
||||
})) passed++;
|
||||
|
||||
if (test("jurisdiction does not grant consent", () => {
|
||||
const consent = createIntegratedConsentEngine({
|
||||
jurisdiction: { mode: "fixed", jurisdiction: "us-ma" }
|
||||
});
|
||||
const state = consent.initialize();
|
||||
equal(state.categories.necessary, "granted");
|
||||
equal(state.categories.preferences, "unknown");
|
||||
equal(state.categories.analytics, "unknown");
|
||||
equal(state.categories.marketing, "unknown");
|
||||
equal(state.hasDecision, false);
|
||||
})) passed++;
|
||||
|
||||
if (test("resolved context is exposed as frozen data", () => {
|
||||
const consent = createIntegratedConsentEngine({
|
||||
jurisdiction: { mode: "fixed", jurisdiction: "eu" }
|
||||
});
|
||||
const context = consent.getJurisdictionContext();
|
||||
equal(context.resolution.jurisdiction.id, "eu");
|
||||
equal(Object.isFrozen(context), true);
|
||||
})) passed++;
|
||||
|
||||
if (test("Core persistence uses resolved jurisdiction", () => {
|
||||
const consent = createIntegratedConsentEngine({
|
||||
jurisdiction: { mode: "fixed", jurisdiction: "us-ma" }
|
||||
});
|
||||
consent.initialize();
|
||||
consent.acceptAll();
|
||||
equal(consent.getState().jurisdiction, "us-ma");
|
||||
equal(consent.getState().hasDecision, true);
|
||||
})) passed++;
|
||||
|
||||
if (test("integration is deterministic", () => {
|
||||
const input = {
|
||||
mode: "auto",
|
||||
request: { jurisdiction: "us-ma", source: "server" },
|
||||
visitor: { jurisdiction: "eu", source: "browser" }
|
||||
};
|
||||
deepEqual(
|
||||
resolveConsentContext(input),
|
||||
resolveConsentContext(input)
|
||||
);
|
||||
})) passed++;
|
||||
|
||||
if (test("invalid jurisdiction remains an Engine error", () => {
|
||||
throws(() => resolveConsentContext({
|
||||
mode: "fixed", jurisdiction: "not-a-jurisdiction"
|
||||
}), /Unknown jurisdiction/);
|
||||
})) passed++;
|
||||
|
||||
summary.textContent = `${passed}/${total} tests passed`;
|
||||
summary.className = passed === total ? "pass" : "fail";
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
resolveConsentContext,
|
||||
createIntegratedConsentEngine
|
||||
} from "../../integration/index.js";
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test("fixed us-ma resolves into Core context", () => {
|
||||
const context = resolveConsentContext({
|
||||
mode: "fixed",
|
||||
jurisdiction: "us-ma"
|
||||
});
|
||||
|
||||
assert.equal(context.resolution.jurisdiction.id, "us-ma");
|
||||
assert.equal(context.resolution.policy.id, "us-state-privacy");
|
||||
});
|
||||
|
||||
test("fixed eu resolves into Core context", () => {
|
||||
const context = resolveConsentContext({
|
||||
mode: "fixed",
|
||||
jurisdiction: "eu"
|
||||
});
|
||||
|
||||
assert.equal(context.resolution.jurisdiction.id, "eu");
|
||||
assert.equal(context.resolution.policy.id, "eu-privacy");
|
||||
});
|
||||
|
||||
test("global resolves into global baseline", () => {
|
||||
const context = resolveConsentContext({ mode: "global" });
|
||||
|
||||
assert.equal(context.resolution.jurisdiction.id, "global");
|
||||
assert.equal(context.resolution.policy.id, "global-baseline");
|
||||
});
|
||||
|
||||
test("integration configures Core jurisdiction", () => {
|
||||
const consent = createIntegratedConsentEngine({
|
||||
jurisdiction: { mode: "fixed", jurisdiction: "us-ma" }
|
||||
});
|
||||
|
||||
const state = consent.initialize();
|
||||
|
||||
assert.equal(state.jurisdiction, "us-ma");
|
||||
assert.equal(state.hasDecision, false);
|
||||
});
|
||||
|
||||
test("jurisdiction does not grant consent", () => {
|
||||
const consent = createIntegratedConsentEngine({
|
||||
jurisdiction: { mode: "fixed", jurisdiction: "us-ma" }
|
||||
});
|
||||
|
||||
const state = consent.initialize();
|
||||
|
||||
assert.equal(state.categories.necessary, "granted");
|
||||
assert.equal(state.categories.preferences, "unknown");
|
||||
assert.equal(state.categories.analytics, "unknown");
|
||||
assert.equal(state.categories.marketing, "unknown");
|
||||
assert.equal(state.hasDecision, false);
|
||||
});
|
||||
|
||||
test("resolved context is exposed without mutable internals", () => {
|
||||
const consent = createIntegratedConsentEngine({
|
||||
jurisdiction: { mode: "fixed", jurisdiction: "eu" }
|
||||
});
|
||||
|
||||
const context = consent.getJurisdictionContext();
|
||||
|
||||
assert.equal(context.resolution.jurisdiction.id, "eu");
|
||||
assert.equal(Object.isFrozen(context), true);
|
||||
assert.equal(Object.isFrozen(context.resolution), true);
|
||||
});
|
||||
|
||||
test("Core configuration preserves resolved jurisdiction", () => {
|
||||
const consent = createIntegratedConsentEngine({
|
||||
jurisdiction: { mode: "fixed", jurisdiction: "us-ma" }
|
||||
});
|
||||
|
||||
const config = consent.getConfig();
|
||||
|
||||
assert.equal(config.jurisdiction, "us-ma");
|
||||
});
|
||||
|
||||
test("Node environment does not fake successful persistence", () => {
|
||||
const consent = createIntegratedConsentEngine({
|
||||
jurisdiction: { mode: "fixed", jurisdiction: "us-ma" }
|
||||
});
|
||||
|
||||
consent.initialize();
|
||||
const state = consent.acceptAll();
|
||||
|
||||
assert.equal(state.jurisdiction, "us-ma");
|
||||
assert.equal(state.hasDecision, false);
|
||||
assert.equal(state.categories.preferences, "unknown");
|
||||
assert.equal(state.categories.analytics, "unknown");
|
||||
assert.equal(state.categories.marketing, "unknown");
|
||||
});
|
||||
|
||||
test("integration remains deterministic", () => {
|
||||
const input = {
|
||||
mode: "auto",
|
||||
request: {
|
||||
jurisdiction: "us-ma",
|
||||
source: "server"
|
||||
},
|
||||
visitor: {
|
||||
jurisdiction: "eu",
|
||||
source: "browser"
|
||||
}
|
||||
};
|
||||
|
||||
const first = resolveConsentContext(input);
|
||||
const second = resolveConsentContext(input);
|
||||
|
||||
assert.deepEqual(first, second);
|
||||
});
|
||||
|
||||
test("invalid jurisdiction remains an Engine error", () => {
|
||||
assert.throws(
|
||||
() => resolveConsentContext({
|
||||
mode: "fixed",
|
||||
jurisdiction: "not-a-jurisdiction"
|
||||
}),
|
||||
/Unknown jurisdiction/
|
||||
);
|
||||
});
|
||||
|
||||
console.log("10 integration tests passed.");
|
||||
@@ -0,0 +1,84 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Jurisdiction Engine Test Lab</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 40px auto; padding: 0 20px; }
|
||||
button { padding: 10px 16px; cursor: pointer; }
|
||||
#summary { margin: 20px 0; font-weight: 700; }
|
||||
.pass { color: #176b2c; }
|
||||
.fail { color: #a51d2d; }
|
||||
pre { white-space: pre-wrap; background: #f5f5f5; padding: 16px; border-radius: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Jurisdiction Engine Test Lab</h1>
|
||||
<p>Browser regression suite for the pure Jurisdiction Engine.</p>
|
||||
<button id="run">Run tests</button>
|
||||
<div id="summary">Not run.</div>
|
||||
<pre id="log"></pre>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
resolveJurisdiction,
|
||||
isValidResolution,
|
||||
getResolutionPriority,
|
||||
getSourceConfidence,
|
||||
hasJurisdiction,
|
||||
hasPolicy,
|
||||
JURISDICTION_CATALOG,
|
||||
POLICIES
|
||||
} from "../jurisdiction/index.js";
|
||||
|
||||
const tests = [
|
||||
["fixed us-ma", () => {
|
||||
const r = resolveJurisdiction({mode:"fixed", jurisdiction:"us-ma"});
|
||||
return r.status === "resolved" && r.jurisdiction.id === "us-ma" && r.policy.id === "us-state-privacy" && r.source === "site-config" && r.confidence === "explicit";
|
||||
}],
|
||||
["fixed eu", () => resolveJurisdiction({mode:"fixed", jurisdiction:"eu"}).policy.id === "eu-privacy"],
|
||||
["fixed global", () => resolveJurisdiction({mode:"fixed", jurisdiction:"global"}).policy.id === "global-baseline"],
|
||||
["global mode", () => resolveJurisdiction({mode:"global"}).jurisdiction.id === "global"],
|
||||
["auto server", () => resolveJurisdiction({mode:"auto", request:{jurisdiction:"us-ca", source:"server"}}).source === "server"],
|
||||
["auto application", () => resolveJurisdiction({mode:"auto", visitor:{jurisdiction:"us-ma", source:"application"}}).source === "application"],
|
||||
["auto browser", () => resolveJurisdiction({mode:"auto", visitor:{jurisdiction:"eu", source:"browser"}}).source === "browser"],
|
||||
["auto fallback", () => { const r=resolveJurisdiction({mode:"auto"}); return r.status === "fallback" && r.jurisdiction.id === "global"; }],
|
||||
["unknown signal fallback", () => resolveJurisdiction({mode:"auto", request:{jurisdiction:"xx-unknown", source:"server"}}).status === "fallback"],
|
||||
["server beats browser", () => resolveJurisdiction({mode:"auto", request:{jurisdiction:"us-ma",source:"server"}, visitor:{jurisdiction:"eu",source:"browser"}}).jurisdiction.id === "us-ma"],
|
||||
["site config beats server", () => resolveJurisdiction({mode:"auto", site:{jurisdiction:"us-ca",source:"site-config"}, request:{jurisdiction:"eu",source:"server"}}).jurisdiction.id === "us-ca"],
|
||||
["fixed requires jurisdiction", () => { try { resolveJurisdiction({mode:"fixed"}); return false; } catch { return true; } }],
|
||||
["fixed rejects unknown", () => { try { resolveJurisdiction({mode:"fixed",jurisdiction:"xx"}); return false; } catch { return true; } }],
|
||||
["invalid mode", () => { try { resolveJurisdiction({mode:"random"}); return false; } catch { return true; } }],
|
||||
["invalid input", () => { try { resolveJurisdiction(null); return false; } catch { return true; } }],
|
||||
["catalog policies exist", () => Object.values(JURISDICTION_CATALOG).every(e => hasPolicy(e.policy))],
|
||||
["result contract", () => [resolveJurisdiction({mode:"fixed",jurisdiction:"us-ma"}),resolveJurisdiction({mode:"global"}),resolveJurisdiction({mode:"auto"})].every(isValidResolution)],
|
||||
["source priority", () => getResolutionPriority("site-config") > getResolutionPriority("server") && getResolutionPriority("server") > getResolutionPriority("application") && getResolutionPriority("application") > getResolutionPriority("browser") && getResolutionPriority("browser") > getResolutionPriority("fallback")],
|
||||
["confidence mapping", () => getSourceConfidence("site-config") === "explicit" && getSourceConfidence("server") === "high" && getSourceConfidence("application") === "medium" && getSourceConfidence("browser") === "low" && getSourceConfidence("fallback") === "default"],
|
||||
["initial catalog", () => ["global","us","us-ma","us-ca","eu"].every(hasJurisdiction) && POLICIES["global-baseline"] !== undefined]
|
||||
];
|
||||
|
||||
const run = document.querySelector("#run");
|
||||
const summary = document.querySelector("#summary");
|
||||
const log = document.querySelector("#log");
|
||||
|
||||
run.addEventListener("click", () => {
|
||||
let passed = 0;
|
||||
const lines = [];
|
||||
for (const [name, fn] of tests) {
|
||||
try {
|
||||
const ok = Boolean(fn());
|
||||
if (!ok) throw new Error("Assertion returned false");
|
||||
passed++;
|
||||
lines.push(`PASS: ${name}`);
|
||||
} catch (error) {
|
||||
lines.push(`FAIL: ${name} — ${error.message}`);
|
||||
}
|
||||
}
|
||||
summary.textContent = `${passed}/${tests.length} tests passed`;
|
||||
summary.className = passed === tests.length ? "pass" : "fail";
|
||||
log.textContent = lines.join("\n");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,196 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
resolveJurisdiction,
|
||||
isValidResolution,
|
||||
getResolutionPriority,
|
||||
getSourceConfidence,
|
||||
hasJurisdiction,
|
||||
hasPolicy,
|
||||
JURISDICTION_CATALOG,
|
||||
POLICIES
|
||||
} from "../jurisdiction/index.js";
|
||||
|
||||
const tests = [];
|
||||
function test(name, fn) {
|
||||
tests.push({ name, fn });
|
||||
}
|
||||
|
||||
test("fixed us-ma resolves from site configuration", () => {
|
||||
const result = resolveJurisdiction({ mode: "fixed", jurisdiction: "us-ma" });
|
||||
assert.equal(result.status, "resolved");
|
||||
assert.equal(result.jurisdiction.id, "us-ma");
|
||||
assert.equal(result.policy.id, "us-state-privacy");
|
||||
assert.equal(result.source, "site-config");
|
||||
assert.equal(result.confidence, "explicit");
|
||||
});
|
||||
|
||||
test("fixed eu resolves correctly", () => {
|
||||
const result = resolveJurisdiction({ mode: "fixed", jurisdiction: "eu" });
|
||||
assert.equal(result.jurisdiction.id, "eu");
|
||||
assert.equal(result.policy.id, "eu-privacy");
|
||||
});
|
||||
|
||||
test("fixed global resolves global baseline", () => {
|
||||
const result = resolveJurisdiction({ mode: "fixed", jurisdiction: "global" });
|
||||
assert.equal(result.jurisdiction.id, "global");
|
||||
assert.equal(result.policy.id, "global-baseline");
|
||||
});
|
||||
|
||||
test("global mode deliberately selects global policy", () => {
|
||||
const result = resolveJurisdiction({ mode: "global" });
|
||||
assert.equal(result.jurisdiction.id, "global");
|
||||
assert.equal(result.source, "site-config");
|
||||
});
|
||||
|
||||
test("auto resolves server signal", () => {
|
||||
const result = resolveJurisdiction({
|
||||
mode: "auto",
|
||||
request: { jurisdiction: "us-ca", source: "server" }
|
||||
});
|
||||
assert.equal(result.jurisdiction.id, "us-ca");
|
||||
assert.equal(result.source, "server");
|
||||
assert.equal(result.confidence, "high");
|
||||
});
|
||||
|
||||
test("auto resolves application signal", () => {
|
||||
const result = resolveJurisdiction({
|
||||
mode: "auto",
|
||||
visitor: { jurisdiction: "us-ma", source: "application" }
|
||||
});
|
||||
assert.equal(result.jurisdiction.id, "us-ma");
|
||||
assert.equal(result.source, "application");
|
||||
});
|
||||
|
||||
test("auto resolves browser signal", () => {
|
||||
const result = resolveJurisdiction({
|
||||
mode: "auto",
|
||||
visitor: { jurisdiction: "eu", source: "browser" }
|
||||
});
|
||||
assert.equal(result.jurisdiction.id, "eu");
|
||||
assert.equal(result.source, "browser");
|
||||
assert.equal(result.confidence, "low");
|
||||
});
|
||||
|
||||
test("auto falls back conservatively when no signal exists", () => {
|
||||
const result = resolveJurisdiction({ mode: "auto" });
|
||||
assert.equal(result.status, "fallback");
|
||||
assert.equal(result.jurisdiction.id, "global");
|
||||
assert.equal(result.policy.id, "global-baseline");
|
||||
assert.equal(result.source, "fallback");
|
||||
assert.equal(result.confidence, "default");
|
||||
});
|
||||
|
||||
test("auto ignores unknown jurisdiction signals", () => {
|
||||
const result = resolveJurisdiction({
|
||||
mode: "auto",
|
||||
request: { jurisdiction: "xx-unknown", source: "server" }
|
||||
});
|
||||
assert.equal(result.status, "fallback");
|
||||
assert.equal(result.jurisdiction.id, "global");
|
||||
});
|
||||
|
||||
test("higher-trust server signal wins over browser signal", () => {
|
||||
const result = resolveJurisdiction({
|
||||
mode: "auto",
|
||||
request: { jurisdiction: "us-ma", source: "server" },
|
||||
visitor: { jurisdiction: "eu", source: "browser" }
|
||||
});
|
||||
assert.equal(result.jurisdiction.id, "us-ma");
|
||||
assert.equal(result.source, "server");
|
||||
});
|
||||
|
||||
test("explicit site configuration wins over lower-trust signals", () => {
|
||||
const result = resolveJurisdiction({
|
||||
mode: "auto",
|
||||
site: { jurisdiction: "us-ca", source: "site-config" },
|
||||
request: { jurisdiction: "eu", source: "server" }
|
||||
});
|
||||
assert.equal(result.jurisdiction.id, "us-ca");
|
||||
assert.equal(result.source, "site-config");
|
||||
});
|
||||
|
||||
test("fixed mode requires jurisdiction", () => {
|
||||
assert.throws(
|
||||
() => resolveJurisdiction({ mode: "fixed" }),
|
||||
/jurisdiction is required/i
|
||||
);
|
||||
});
|
||||
|
||||
test("fixed mode rejects unknown jurisdiction", () => {
|
||||
assert.throws(
|
||||
() => resolveJurisdiction({ mode: "fixed", jurisdiction: "xx" }),
|
||||
/unknown jurisdiction/i
|
||||
);
|
||||
});
|
||||
|
||||
test("invalid mode is rejected", () => {
|
||||
assert.throws(
|
||||
() => resolveJurisdiction({ mode: "random" }),
|
||||
/invalid jurisdiction mode/i
|
||||
);
|
||||
});
|
||||
|
||||
test("invalid input type is rejected", () => {
|
||||
assert.throws(
|
||||
() => resolveJurisdiction(null),
|
||||
/input must be an object/i
|
||||
);
|
||||
});
|
||||
|
||||
test("catalog entries reference existing policies", () => {
|
||||
for (const entry of Object.values(JURISDICTION_CATALOG)) {
|
||||
assert.equal(hasPolicy(entry.policy), true);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolution result satisfies its contract", () => {
|
||||
const results = [
|
||||
resolveJurisdiction({ mode: "fixed", jurisdiction: "us-ma" }),
|
||||
resolveJurisdiction({ mode: "global" }),
|
||||
resolveJurisdiction({ mode: "auto" })
|
||||
];
|
||||
|
||||
for (const result of results) {
|
||||
assert.equal(isValidResolution(result), true);
|
||||
}
|
||||
});
|
||||
|
||||
test("source priority is deterministic", () => {
|
||||
assert.ok(getResolutionPriority("site-config") > getResolutionPriority("server"));
|
||||
assert.ok(getResolutionPriority("server") > getResolutionPriority("application"));
|
||||
assert.ok(getResolutionPriority("application") > getResolutionPriority("browser"));
|
||||
assert.ok(getResolutionPriority("browser") > getResolutionPriority("fallback"));
|
||||
});
|
||||
|
||||
test("source confidence mapping is deterministic", () => {
|
||||
assert.equal(getSourceConfidence("site-config"), "explicit");
|
||||
assert.equal(getSourceConfidence("server"), "high");
|
||||
assert.equal(getSourceConfidence("application"), "medium");
|
||||
assert.equal(getSourceConfidence("browser"), "low");
|
||||
assert.equal(getSourceConfidence("fallback"), "default");
|
||||
});
|
||||
|
||||
test("catalog contains the initial architectural jurisdictions", () => {
|
||||
for (const id of ["global", "us", "us-ma", "us-ca", "eu"]) {
|
||||
assert.equal(hasJurisdiction(id), true);
|
||||
}
|
||||
assert.equal(POLICIES["global-baseline"] !== undefined, true);
|
||||
});
|
||||
|
||||
let passed = 0;
|
||||
for (const { name, fn } of tests) {
|
||||
try {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(`PASS: ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL: ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${passed}/${tests.length} tests passed`);
|
||||
if (passed !== tests.length) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createConsentEngine } from "../../core/consent.js";
|
||||
import {
|
||||
isStoragePayload
|
||||
} from "../../core/storage.js";
|
||||
import {
|
||||
isValidCategoriesObject
|
||||
} from "../../core/categories.js";
|
||||
|
||||
let jar = new Map();
|
||||
let blockWrites = false;
|
||||
|
||||
globalThis.location = { protocol: "http:" };
|
||||
globalThis.document = {};
|
||||
|
||||
Object.defineProperty(globalThis.document, "cookie", {
|
||||
configurable: true,
|
||||
|
||||
get() {
|
||||
return [...jar.entries()]
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("; ");
|
||||
},
|
||||
|
||||
set(value) {
|
||||
if (blockWrites) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = value.split(";").map((item) => item.trim());
|
||||
const [name, raw = ""] = parts[0].split("=");
|
||||
|
||||
const maxAge = parts.find((item) => /^Max-Age=/i.test(item));
|
||||
|
||||
if (maxAge && maxAge.toLowerCase() === "max-age=0") {
|
||||
jar.delete(name);
|
||||
return;
|
||||
}
|
||||
|
||||
jar.set(name, raw);
|
||||
}
|
||||
});
|
||||
|
||||
function clearEnvironment() {
|
||||
jar.clear();
|
||||
blockWrites = false;
|
||||
}
|
||||
|
||||
function make(options = {}) {
|
||||
return createConsentEngine({
|
||||
policyVersion: "2026-01",
|
||||
jurisdiction: "global",
|
||||
storage: {
|
||||
name: "cc_reliability",
|
||||
duration: 180,
|
||||
path: "/",
|
||||
sameSite: "Lax"
|
||||
},
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
clearEnvironment();
|
||||
|
||||
try {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Input contract */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
test("engine accepts omitted configuration and applies defaults", () => {
|
||||
const consent = createConsentEngine();
|
||||
|
||||
consent.initialize();
|
||||
|
||||
const state = consent.getState();
|
||||
|
||||
assert.equal(state.hasDecision, false);
|
||||
assert.equal(state.categories.necessary, "granted");
|
||||
assert.equal(state.categories.preferences, "unknown");
|
||||
assert.equal(state.categories.analytics, "unknown");
|
||||
assert.equal(state.categories.marketing, "unknown");
|
||||
});
|
||||
|
||||
test("rejects invalid consent category value", () => {
|
||||
const consent = make();
|
||||
|
||||
consent.initialize();
|
||||
|
||||
assert.throws(
|
||||
() => consent.setConsent({
|
||||
analytics: "invalid"
|
||||
}),
|
||||
TypeError
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects denial of necessary category", () => {
|
||||
const consent = make();
|
||||
|
||||
consent.initialize();
|
||||
|
||||
assert.throws(
|
||||
() => consent.setConsent({
|
||||
necessary: "denied"
|
||||
}),
|
||||
TypeError
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* State isolation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
test("separate engines do not share state", () => {
|
||||
const first = make();
|
||||
const second = make();
|
||||
|
||||
first.initialize();
|
||||
second.initialize();
|
||||
|
||||
first.acceptAll();
|
||||
|
||||
assert.equal(first.getState().hasDecision, true);
|
||||
assert.equal(second.getState().hasDecision, false);
|
||||
});
|
||||
|
||||
test("returned state mutation cannot alter internal categories", () => {
|
||||
const consent = make();
|
||||
|
||||
consent.initialize();
|
||||
|
||||
const state = consent.getState();
|
||||
|
||||
state.categories.analytics = "granted";
|
||||
state.categories.marketing = "granted";
|
||||
|
||||
const current = consent.getState();
|
||||
|
||||
assert.equal(current.categories.analytics, "unknown");
|
||||
assert.equal(current.categories.marketing, "unknown");
|
||||
});
|
||||
|
||||
test("returned state mutation cannot alter jurisdiction", () => {
|
||||
const consent = make({
|
||||
jurisdiction: "us-ma"
|
||||
});
|
||||
|
||||
consent.initialize();
|
||||
|
||||
const state = consent.getState();
|
||||
|
||||
state.jurisdiction = "eu";
|
||||
|
||||
assert.equal(
|
||||
consent.getState().jurisdiction,
|
||||
"us-ma"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Consent transitions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
test("partial consent does not become a complete decision", () => {
|
||||
const consent = make();
|
||||
|
||||
consent.initialize();
|
||||
|
||||
const state = consent.setConsent({
|
||||
analytics: "granted"
|
||||
});
|
||||
|
||||
assert.equal(state.categories.analytics, "granted");
|
||||
assert.equal(state.categories.preferences, "unknown");
|
||||
assert.equal(state.categories.marketing, "unknown");
|
||||
assert.equal(state.hasDecision, false);
|
||||
});
|
||||
|
||||
test("complete selective consent creates a decision", () => {
|
||||
const consent = make();
|
||||
|
||||
consent.initialize();
|
||||
|
||||
const state = consent.setConsent({
|
||||
preferences: "denied",
|
||||
analytics: "granted",
|
||||
marketing: "denied"
|
||||
});
|
||||
|
||||
assert.equal(state.hasDecision, true);
|
||||
assert.equal(state.categories.necessary, "granted");
|
||||
});
|
||||
|
||||
test("acceptAll is idempotent", () => {
|
||||
const consent = make();
|
||||
|
||||
consent.initialize();
|
||||
|
||||
let changed = 0;
|
||||
let accepted = 0;
|
||||
|
||||
consent.on("consent:changed", () => changed++);
|
||||
consent.on("consent:accepted", () => accepted++);
|
||||
|
||||
const first = consent.acceptAll();
|
||||
const second = consent.acceptAll();
|
||||
|
||||
assert.equal(first.hasDecision, true);
|
||||
assert.equal(second.hasDecision, true);
|
||||
assert.equal(changed, 1);
|
||||
assert.equal(accepted, 1);
|
||||
assert.equal(second.timestamp, first.timestamp);
|
||||
});
|
||||
|
||||
test("rejectAll is idempotent", () => {
|
||||
const consent = make();
|
||||
|
||||
consent.initialize();
|
||||
|
||||
let changed = 0;
|
||||
let rejected = 0;
|
||||
|
||||
consent.on("consent:changed", () => changed++);
|
||||
consent.on("consent:rejected", () => rejected++);
|
||||
|
||||
const first = consent.rejectAll();
|
||||
const second = consent.rejectAll();
|
||||
|
||||
assert.equal(first.hasDecision, true);
|
||||
assert.equal(second.hasDecision, true);
|
||||
assert.equal(changed, 1);
|
||||
assert.equal(rejected, 1);
|
||||
});
|
||||
|
||||
test("revoke after acceptance creates the Core-defined denied state", () => {
|
||||
const consent = make();
|
||||
|
||||
consent.initialize();
|
||||
consent.acceptAll();
|
||||
|
||||
let revoked = 0;
|
||||
|
||||
consent.on("consent:revoked", () => revoked++);
|
||||
|
||||
const first = consent.revoke();
|
||||
const second = consent.revoke();
|
||||
|
||||
assert.equal(first.categories.necessary, "granted");
|
||||
assert.equal(first.categories.preferences, "denied");
|
||||
assert.equal(first.categories.analytics, "denied");
|
||||
assert.equal(first.categories.marketing, "denied");
|
||||
|
||||
assert.equal(second.categories.analytics, "denied");
|
||||
assert.equal(revoked, 1);
|
||||
});
|
||||
|
||||
test("reset returns the engine to undecided state", () => {
|
||||
const consent = make();
|
||||
|
||||
consent.initialize();
|
||||
consent.acceptAll();
|
||||
|
||||
const state = consent.reset();
|
||||
|
||||
assert.equal(state.hasDecision, false);
|
||||
assert.equal(state.categories.necessary, "granted");
|
||||
assert.equal(state.categories.preferences, "unknown");
|
||||
assert.equal(state.categories.analytics, "unknown");
|
||||
assert.equal(state.categories.marketing, "unknown");
|
||||
});
|
||||
|
||||
test("reset is idempotent", () => {
|
||||
const consent = make();
|
||||
|
||||
consent.initialize();
|
||||
|
||||
let reset = 0;
|
||||
|
||||
consent.on("consent:reset", () => reset++);
|
||||
|
||||
const first = consent.reset();
|
||||
const second = consent.reset();
|
||||
|
||||
assert.equal(first.hasDecision, false);
|
||||
assert.equal(second.hasDecision, false);
|
||||
assert.equal(reset, 2);
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* GPC */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
test("GPC never grants optional consent", () => {
|
||||
const consent = make();
|
||||
|
||||
consent.initialize();
|
||||
|
||||
const state = consent.setGpc(true);
|
||||
|
||||
assert.equal(state.gpc, true);
|
||||
assert.equal(state.hasDecision, false);
|
||||
assert.equal(state.categories.preferences, "unknown");
|
||||
assert.equal(state.categories.analytics, "unknown");
|
||||
assert.equal(state.categories.marketing, "unknown");
|
||||
});
|
||||
|
||||
test("GPC can coexist with explicit consent without changing categories", () => {
|
||||
const consent = make();
|
||||
|
||||
consent.initialize();
|
||||
|
||||
consent.acceptAll();
|
||||
|
||||
const state = consent.setGpc(true);
|
||||
|
||||
assert.equal(state.gpc, true);
|
||||
assert.equal(state.categories.analytics, "granted");
|
||||
assert.equal(state.categories.marketing, "granted");
|
||||
});
|
||||
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Persistence */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
test("accepted consent survives a fresh engine instance", () => {
|
||||
const first = make();
|
||||
|
||||
first.initialize();
|
||||
first.acceptAll();
|
||||
|
||||
const second = make();
|
||||
|
||||
second.initialize();
|
||||
|
||||
const state = second.getState();
|
||||
|
||||
assert.equal(state.source, "storage");
|
||||
assert.equal(state.hasDecision, true);
|
||||
assert.equal(state.categories.analytics, "granted");
|
||||
});
|
||||
|
||||
test("rejected consent survives a fresh engine instance", () => {
|
||||
const first = make();
|
||||
|
||||
first.initialize();
|
||||
first.rejectAll();
|
||||
|
||||
const second = make();
|
||||
|
||||
second.initialize();
|
||||
|
||||
assert.equal(
|
||||
second.getState().categories.analytics,
|
||||
"denied"
|
||||
);
|
||||
});
|
||||
|
||||
test("policy mismatch invalidates persisted consent", () => {
|
||||
const first = make();
|
||||
|
||||
first.initialize();
|
||||
first.acceptAll();
|
||||
|
||||
const second = make({
|
||||
policyVersion: "2027-01"
|
||||
});
|
||||
|
||||
second.initialize();
|
||||
|
||||
assert.equal(second.isComplete(), false);
|
||||
assert.equal(document.cookie, "");
|
||||
});
|
||||
|
||||
test("invalid persisted payload is ignored and removed", () => {
|
||||
const bad = {
|
||||
engineVersion: "invalid",
|
||||
policyVersion: "2026-01",
|
||||
timestamp: Date.now(),
|
||||
categories: {
|
||||
necessary: "granted",
|
||||
preferences: "banana",
|
||||
analytics: "granted",
|
||||
marketing: "denied"
|
||||
},
|
||||
jurisdiction: "global",
|
||||
gpc: false
|
||||
};
|
||||
|
||||
document.cookie =
|
||||
`cc_reliability=${encodeURIComponent(JSON.stringify(bad))}; Path=/; SameSite=Lax`;
|
||||
|
||||
const consent = make();
|
||||
|
||||
consent.initialize();
|
||||
|
||||
assert.equal(consent.isComplete(), false);
|
||||
assert.equal(document.cookie, "");
|
||||
});
|
||||
test("storage removal failure does not falsely report reset", () => {
|
||||
const consent = make();
|
||||
|
||||
consent.initialize();
|
||||
consent.acceptAll();
|
||||
|
||||
blockWrites = true;
|
||||
|
||||
let resetEvents = 0;
|
||||
let storageErrors = 0;
|
||||
|
||||
consent.on("consent:reset", () => resetEvents++);
|
||||
consent.on("storage:error", () => storageErrors++);
|
||||
|
||||
consent.reset();
|
||||
|
||||
assert.equal(resetEvents, 0);
|
||||
assert.equal(storageErrors, 1);
|
||||
assert.equal(consent.getState().hasDecision, true);
|
||||
});
|
||||
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Data contracts */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
test("valid categories object satisfies its contract", () => {
|
||||
assert.equal(
|
||||
isValidCategoriesObject({
|
||||
necessary: "granted",
|
||||
preferences: "unknown",
|
||||
analytics: "granted",
|
||||
marketing: "denied"
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("invalid categories object fails its contract", () => {
|
||||
assert.equal(
|
||||
isValidCategoriesObject({
|
||||
necessary: "granted",
|
||||
preferences: "invalid",
|
||||
analytics: "granted",
|
||||
marketing: "denied"
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("valid stored envelope satisfies its contract", () => {
|
||||
assert.equal(
|
||||
isStoragePayload({
|
||||
engineVersion: "1.0.0",
|
||||
policyVersion: "2026-01",
|
||||
timestamp: Date.now(),
|
||||
categories: {
|
||||
necessary: "granted",
|
||||
preferences: "unknown",
|
||||
analytics: "granted",
|
||||
marketing: "denied"
|
||||
},
|
||||
jurisdiction: "global",
|
||||
gpc: false
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("empty stored envelope fails its contract", () => {
|
||||
assert.equal(
|
||||
isStoragePayload({}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Lifecycle */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
test("initialize is idempotent", () => {
|
||||
const consent = make();
|
||||
|
||||
let initialized = 0;
|
||||
|
||||
consent.on("initialized", () => initialized++);
|
||||
|
||||
consent.initialize();
|
||||
consent.initialize();
|
||||
consent.initialize();
|
||||
|
||||
assert.equal(initialized, 1);
|
||||
});
|
||||
|
||||
test("initialize without stored consent starts undecided", () => {
|
||||
const consent = make();
|
||||
|
||||
consent.initialize();
|
||||
|
||||
const state = consent.getState();
|
||||
|
||||
assert.equal(state.hasDecision, false);
|
||||
assert.equal(state.source, "default");
|
||||
assert.equal(state.categories.necessary, "granted");
|
||||
assert.equal(state.categories.analytics, "unknown");
|
||||
});
|
||||
|
||||
|
||||
console.log(`\n${passed} reliability tests passed.`);
|
||||
@@ -0,0 +1,70 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cookie Consent Core — Hardening Test Lab</title>
|
||||
<style>
|
||||
body{font-family:system-ui,sans-serif;margin:0;background:#f5f7fa;color:#1f2937}.wrap{max-width:1000px;margin:32px auto;padding:0 16px}.card{background:#fff;border:1px solid #dfe4ea;border-radius:12px;padding:20px;margin-bottom:18px}button{padding:9px 13px;border:1px solid #cbd2da;border-radius:7px;background:#fff;cursor:pointer}button.primary{background:#1f2937;color:#fff}.actions{display:flex;gap:8px;flex-wrap:wrap}.pass{color:#137333}.fail{color:#b42318}.neutral{color:#697386}.test{display:flex;justify-content:space-between;border-bottom:1px solid #eee;padding:10px 0;gap:16px}.test:last-child{border:0}pre{background:#111827;color:#e5e7eb;padding:16px;border-radius:8px;overflow:auto}code{font-family:ui-monospace,monospace}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header><h1>Cookie Consent Core — Hardening Test Lab</h1><p>Regression and robustness tests for Core v1.0.1.</p></header>
|
||||
<section class="card"><h2>Controls</h2><div class="actions"><button id="run" class="primary">Run tests</button><button id="accept">Accept All</button><button id="reject">Reject All</button><button id="revoke">Revoke</button><button id="reset">Reset</button><button id="refresh">Refresh</button></div></section>
|
||||
<section class="card"><h2>State</h2><pre id="state">—</pre></section>
|
||||
<section class="card"><h2>Tests</h2><div id="tests"></div><p id="summary" class="neutral">Not run.</p></section>
|
||||
<section class="card"><h2>Cookie</h2><pre id="cookie">—</pre></section>
|
||||
<section class="card"><h2>Event log</h2><pre id="log">—</pre></section>
|
||||
</div>
|
||||
<script type="module">
|
||||
import { createConsentEngine, ENGINE_VERSION } from "../core/consent.js";
|
||||
import { isStoragePayload } from "../core/storage.js";
|
||||
import { isValidCategoriesObject } from "../core/categories.js";
|
||||
|
||||
const config={policyVersion:"2026-01",jurisdiction:"global",storage:{name:"cc_consent",duration:180,path:"/",sameSite:"Lax"}};
|
||||
const engine=createConsentEngine(config);
|
||||
const log=[];
|
||||
const events=["initialized","consent:loaded","consent:accepted","consent:rejected","consent:changed","consent:revoked","consent:expired","consent:reset","storage:error"];
|
||||
for(const name of events) engine.on(name,payload=>{log.push({time:new Date().toLocaleTimeString(),event:name,payload});render();});
|
||||
|
||||
function render(){document.querySelector("#state").textContent=JSON.stringify(engine.getState(),null,2);document.querySelector("#cookie").textContent=document.cookie||"(no cookie)";document.querySelector("#log").textContent=JSON.stringify(log,null,2)}
|
||||
function assert(c,m){if(!c)throw new Error(m)}
|
||||
function clean(){engine.reset();document.cookie="cc_consent=; Max-Age=0; Path=/; SameSite=Lax"}
|
||||
function test(name,fn){try{fn();return [name,true] }catch(e){return [name,false,e.message]}}
|
||||
function run(){
|
||||
const results=[];
|
||||
function setCookiePayload(payload){
|
||||
document.cookie="cc_consent="+encodeURIComponent(JSON.stringify(payload))+"; Path=/; SameSite=Lax";
|
||||
}
|
||||
function clearDirect(){ document.cookie="cc_consent=; Max-Age=0; Path=/; SameSite=Lax"; }
|
||||
clean(); engine.initialize();
|
||||
results.push(test("Default / unknown",()=>{const s=engine.getState();assert(s.categories.necessary==="granted","necessary");assert(s.categories.analytics==="unknown","analytics");assert(!s.hasDecision,"decision") }));
|
||||
results.push(test("Accept All",()=>{engine.acceptAll();const s=engine.getState();assert(s.categories.analytics==="granted","analytics");assert(s.hasDecision,"decision")}));
|
||||
results.push(test("Accept All idempotent",()=>{const t=engine.getState().timestamp;let n=0;const off=engine.on("consent:changed",()=>n++);engine.acceptAll();off();assert(n===0,"duplicate changed event");assert(engine.getState().timestamp===t,"timestamp changed")}));
|
||||
results.push(test("Persistence",()=>{const e=createConsentEngine(config);e.initialize();assert(e.getState().source==="storage","source");assert(e.hasConsent("analytics"),"analytics")}));
|
||||
results.push(test("Reject All",()=>{engine.rejectAll();const s=engine.getState();assert(s.categories.analytics==="denied","analytics");assert(s.categories.necessary==="granted","necessary")}));
|
||||
results.push(test("Reject All idempotent",()=>{let n=0;const off=engine.on("consent:changed",()=>n++);engine.rejectAll();off();assert(n===0,"duplicate changed event")}));
|
||||
results.push(test("Selective",()=>{engine.setConsent({preferences:"denied",analytics:"granted",marketing:"denied"});assert(engine.isComplete(),"complete");assert(engine.hasConsent("analytics"),"analytics")}));
|
||||
results.push(test("Invalid value rejected",()=>{let ok=false;try{engine.setConsent({analytics:"banana"})}catch{ok=true}assert(ok,"must throw")}));
|
||||
results.push(test("Necessary cannot be denied",()=>{let ok=false;try{engine.setConsent({necessary:"denied"})}catch{ok=true}assert(ok,"must throw")}));
|
||||
results.push(test("Revoke",()=>{engine.acceptAll();engine.revoke();assert(engine.getState().categories.analytics==="denied","analytics")}));
|
||||
results.push(test("Revoke idempotent",()=>{let n=0;const off=engine.on("consent:revoked",()=>n++);engine.revoke();off();assert(n===0,"duplicate revoke")}));
|
||||
results.push(test("Reset",()=>{engine.acceptAll();engine.reset();assert(!engine.isComplete(),"decision");assert(!document.cookie,"cookie remains")}));
|
||||
results.push(test("Initialize idempotent",()=>{let n=0;const e=createConsentEngine(config);e.on("initialized",()=>n++);e.initialize();e.initialize();assert(n===1,"initialized twice")}));
|
||||
results.push(test("Strict stored envelope validation",()=>{assert(!isStoragePayload({}),"empty payload accepted");assert(isStoragePayload({engineVersion:ENGINE_VERSION,policyVersion:"2026-01",timestamp:Date.now(),categories:{},jurisdiction:"global",gpc:false}),"valid envelope rejected")}));
|
||||
results.push(test("Strict category validation",()=>{assert(isValidCategoriesObject({necessary:"granted",preferences:"unknown",analytics:"granted",marketing:"denied"}),"valid categories rejected");assert(!isValidCategoriesObject({necessary:"denied",preferences:"unknown",analytics:"granted",marketing:"denied"}),"necessary denied accepted");assert(!isValidCategoriesObject({necessary:"granted",preferences:"banana",analytics:"granted",marketing:"denied"}),"invalid category accepted")}));
|
||||
results.push(test("Policy version mismatch",()=>{clean();const e=createConsentEngine(config);e.initialize();e.acceptAll();const fresh=createConsentEngine({...config,policyVersion:"2027-01"});fresh.initialize();assert(!fresh.isComplete(),"old policy accepted");assert(!document.cookie,"old cookie remains")}));
|
||||
results.push(test("Expired cookie",()=>{clean();const e=createConsentEngine(config);e.initialize();e.acceptAll();const raw=decodeURIComponent(document.cookie.split("=").slice(1).join("="));const payload=JSON.parse(raw);payload.timestamp=Date.now()-181*24*60*60*1000;setCookiePayload(payload);const fresh=createConsentEngine(config);let expired=0;fresh.on("consent:expired",()=>expired++);fresh.initialize();assert(expired===1,"expired event");assert(!fresh.isComplete(),"expired consent accepted");assert(!document.cookie,"expired cookie remains")}));
|
||||
results.push(test("Invalid persisted category payload",()=>{clean();const bad={engineVersion:ENGINE_VERSION,policyVersion:"2026-01",timestamp:Date.now(),categories:{necessary:"granted",preferences:"banana",analytics:"granted",marketing:"denied"},jurisdiction:"global",gpc:false};setCookiePayload(bad);const e=createConsentEngine(config);e.initialize();assert(!e.isComplete(),"invalid payload accepted");assert(!document.cookie,"invalid cookie remains")}));
|
||||
results.push(test("Future timestamp",()=>{clean();const e=createConsentEngine(config);e.initialize();e.acceptAll();const raw=decodeURIComponent(document.cookie.split("=").slice(1).join("="));const payload=JSON.parse(raw);payload.timestamp=Date.now()+60*60*1000;setCookiePayload(payload);const fresh=createConsentEngine(config);fresh.initialize();assert(!fresh.isComplete(),"future timestamp accepted");assert(!document.cookie,"future cookie remains")}));
|
||||
const failed=results.filter(x=>!x[1]);
|
||||
document.querySelector("#tests").innerHTML=results.map(x=>`<div class="test"><span>${x[0]}${x[2]?` — ${x[2]}`:""}</span><strong class="${x[1]?"pass":"fail"}">${x[1]?"PASS":"FAIL"}</strong></div>`).join("");
|
||||
document.querySelector("#summary").textContent=`${results.length-failed.length}/${results.length} tests passed`;
|
||||
document.querySelector("#summary").className=failed.length?"fail":"pass";
|
||||
render();
|
||||
}
|
||||
document.querySelector("#run").onclick=run;document.querySelector("#accept").onclick=()=>{engine.acceptAll();render()};document.querySelector("#reject").onclick=()=>{engine.rejectAll();render()};document.querySelector("#revoke").onclick=()=>{engine.revoke();render()};document.querySelector("#reset").onclick=()=>{engine.reset();render()};document.querySelector("#refresh").onclick=render;engine.initialize();render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user