fix(banner): bridge blocker state loader↔bundle and sweep stale cookies on consent change (#4)

* fix(banner): bridge blocker state between loader and bundle

``consent-loader.js`` and ``consent-bundle.js`` are built as two
separate rollup IIFEs, so each inlines a private copy of
``blocker.ts``. The loader's copy is the one that actually matters
— it installs the ``document.cookie`` / ``Storage.prototype.setItem``
/ ``MutationObserver`` proxies, and those proxies close over the
loader's private ``acceptedCategories`` set and its
``blockedScripts`` queue.

The bundle used to ``import { updateAcceptedCategories } from './blocker'``
and call it from ``handleConsent``. That updated the **bundle's**
dead-end copy of the blocker module — a different ``Set`` object in
a different scope from the one the proxies read — so after the user
granted consent the loader's proxies stayed stuck on
``Set(['necessary'])``, any non-necessary cookie write kept being
silently dropped, and the loader's queue of blocked scripts was
never released.

Fix by exposing the loader's live ``updateAcceptedCategories`` on
``window.__consentos._updateBlocker`` right after
``installBlocker()``, and replacing the bundle's dead-end import
with a helper that calls through the bridge. The bundle no longer
imports from ``./blocker`` at all; rollup tree-shakes the bundle's
copy out, so ``consent-bundle.js`` gets slightly smaller.

Tests (``__tests__/blocker-bridge.test.ts``) cover:
* Bridge is called with the exact accepted list.
* Bridge is forwarded verbatim (no slug filtering).
* Missing bridge logs a warning and doesn't throw.
* Missing ``window.__consentos`` logs a warning and doesn't throw.

``vi.hoisted`` seeds ``window.__consentos`` before banner.ts's
module-level ``init()`` runs so the import-time IIFE doesn't throw
on the empty global.

* fix(banner): sweep disallowed cookies + storage on consent update

When the loader runs it now proactively deletes any pre-existing
cookies (and localStorage / sessionStorage keys) that classify into
a category the visitor hasn't consented to. Fixes the common case
of an ``_ga`` that slipped through before the loader was installed
— e.g. on a host page that loads the loader with ``async`` or
places another tracker above it in ``<head>`` — sitting there
forever because the blocker's only defence was a setter proxy on
future writes.

Sweep semantics:

- Runs on every ``updateAcceptedCategories`` call (consent narrowed
  → the just-revoked categories' cookies are wiped) plus an
  explicit call from the loader's "no consent yet" branch (initial
  visit with pre-existing trackers from elsewhere).
- Only deletes cookies / keys whose classifier hits a known
  pattern (``_ga``, ``_fbp``, ``intercom-*`` etc. — same lists as
  the proxied setters). Unknown / unclassified cookies are left
  alone: we can't attribute them and won't risk clobbering
  first-party session state.
- ``_consentos_*`` is always preserved.
- For cookies, we don't know the original ``domain`` / ``path``
  (``document.cookie`` doesn't expose them), so we fire deletes
  against every plausible domain variant — bare hostname, leading
  ``.``, and every parent domain walked up from the left. Covers
  the GA "set on ``.example.com`` from a subdomain page" case
  without over-deleting.
- Deletions bypass the proxied setter and go directly through the
  cached ``originalCookieDescriptor`` captured before the proxy was
  installed, so the blocker doesn't eat its own expiry writes.
- Storage access is wrapped in ``safeStorage`` — sandboxed /
  cross-origin iframes that throw on ``window.localStorage`` are
  skipped rather than crashing the loader.

Tests in ``__tests__/blocker.test.ts`` cover: non-consented analytics
cookies are deleted, consented ones are preserved, unknown cookies
survive, ``_consentos_*`` is untouched, revoking a category after
seeding new cookies triggers a follow-up sweep, and localStorage
cleanup follows the same rules. 6 new cases, 373 passing total in
the banner suite.
This commit is contained in:
James Cottrill
2026-04-14 17:30:02 +01:00
committed by GitHub
parent 0fbe2717f2
commit bd465008e5
5 changed files with 416 additions and 6 deletions

View File

@@ -0,0 +1,116 @@
/**
* Tests for the loader ↔ bundle blocker bridge.
*
* ``consent-loader.js`` and ``consent-bundle.js`` are compiled as
* separate rollup IIFEs, so each one inlines its own copy of
* ``blocker.ts`` with private module state. The bundle therefore
* can't reach the loader's ``acceptedCategories`` set via a direct
* import — it has to call through ``window.__consentos._updateBlocker``,
* which the loader sets after ``installBlocker()``.
*
* We mock the imports the banner module pulls in so importing
* ``banner.ts`` here doesn't try to hit real network / timers.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Seed ``window.__consentos`` before banner.ts's init() IIFE runs at
// import time. Without this, destructuring ``window.__consentos`` at
// the top of init() throws and fills the test output with noise.
vi.hoisted(() => {
(globalThis as any).window = (globalThis as any).window || globalThis;
(globalThis as any).window.__consentos = {
siteId: '',
apiBase: '',
cdnBase: '',
loaded: false,
};
});
vi.mock('../consent', () => ({
buildConsentState: vi.fn(() => ({ accepted: [], rejected: [] })),
readConsent: vi.fn(() => null),
writeConsent: vi.fn(),
}));
vi.mock('../gcm', () => ({
buildGcmStateFromCategories: vi.fn(() => ({})),
updateGcm: vi.fn(),
}));
vi.mock('../i18n', () => ({
DEFAULT_TRANSLATIONS: {},
detectLocale: vi.fn(() => 'en'),
interpolate: vi.fn((s: string) => s),
loadTranslations: vi.fn(async () => ({})),
renderLinks: vi.fn((s: string) => s),
}));
vi.mock('../a11y', () => ({
announce: vi.fn(),
createLiveRegion: vi.fn(),
focusFirst: vi.fn(),
onEscape: vi.fn(() => () => {}),
prefersReducedMotion: vi.fn(() => false),
trapFocus: vi.fn(() => () => {}),
}));
// Prevent banner.ts's init() IIFE from running against real globals.
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('mocked'))));
import { updateAcceptedCategories } from '../banner';
describe('loader ↔ bundle blocker bridge', () => {
beforeEach(() => {
window.__consentos = {
siteId: 'test',
apiBase: 'https://api.example.com',
cdnBase: 'https://cdn.example.com',
loaded: false,
};
});
it('calls window.__consentos._updateBlocker when the bridge is present', () => {
const bridge = vi.fn();
window.__consentos._updateBlocker = bridge;
updateAcceptedCategories(['necessary', 'analytics']);
expect(bridge).toHaveBeenCalledTimes(1);
expect(bridge).toHaveBeenCalledWith(['necessary', 'analytics']);
});
it('forwards the exact array reference so the loader sees every slug', () => {
const bridge = vi.fn();
window.__consentos._updateBlocker = bridge;
const accepted = ['necessary', 'functional', 'marketing'] as const;
updateAcceptedCategories([...accepted]);
const args = bridge.mock.calls[0][0];
expect(args).toEqual(['necessary', 'functional', 'marketing']);
});
it('warns and returns cleanly when the bridge is missing', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
delete window.__consentos._updateBlocker;
expect(() => updateAcceptedCategories(['necessary'])).not.toThrow();
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('blocker bridge missing'),
);
warn.mockRestore();
});
it('warns when window.__consentos is missing entirely', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
// @ts-expect-error — simulating a pre-init state
window.__consentos = undefined;
expect(() => updateAcceptedCategories(['necessary'])).not.toThrow();
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});

View File

@@ -7,6 +7,7 @@ import {
isCategoryAllowed,
addScriptPatterns,
loadInitiatorMappings,
sweepDisallowedState,
} from '../blocker';
describe('blocker', () => {
@@ -330,4 +331,109 @@ describe('blocker', () => {
expect(isCategoryAllowed('necessary')).toBe(true);
});
});
describe('sweepDisallowedState', () => {
/**
* Reset the cookie jar by expiring any test cookies we know
* about. ``document.cookie =`` runs through the proxy so
* ``_consentos_*`` passes the shortcut and everything else gets
* eaten by the blocker's classifier. Expiring the well-known
* analytics cookies here gives the individual tests a clean
* starting point without relying on the jsdom harness to wipe
* between tests.
*/
function resetCookies(names: string[]) {
for (const name of names) {
document.cookie = `_consentos_reset_marker=${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}
}
beforeEach(() => {
// Directly seed cookies via the native descriptor so the
// proxied setter doesn't eat them as "tracker writes with
// no consent".
const nativeSet = Object.getOwnPropertyDescriptor(
Document.prototype,
'cookie',
)?.set;
if (!nativeSet) throw new Error('cannot locate native cookie setter');
nativeSet.call(
document,
'_ga=GA1.2.seed; path=/',
);
nativeSet.call(
document,
'_fbp=fb.2.seed; path=/',
);
nativeSet.call(
document,
'unknown_cookie=opaque; path=/',
);
nativeSet.call(
document,
'_consentos_consent=%7B%7D; path=/',
);
});
afterEach(() => {
resetCookies(['_ga', '_fbp', 'unknown_cookie', '_consentos_consent']);
});
it('deletes non-consented analytics cookies', () => {
updateAcceptedCategories(['necessary']);
// ^^ updateAcceptedCategories calls sweep internally; the
// assertions below verify the post-sweep cookie jar.
expect(document.cookie).not.toContain('_ga=');
expect(document.cookie).not.toContain('_fbp=');
});
it('leaves consented cookies alone', () => {
updateAcceptedCategories(['necessary', 'analytics', 'marketing']);
expect(document.cookie).toContain('_ga=');
expect(document.cookie).toContain('_fbp=');
});
it('leaves unknown cookies alone even without consent', () => {
updateAcceptedCategories(['necessary']);
expect(document.cookie).toContain('unknown_cookie=');
});
it('never touches _consentos_* cookies', () => {
updateAcceptedCategories(['necessary']);
expect(document.cookie).toContain('_consentos_consent=');
});
it('standalone sweepDisallowedState respects the current set', () => {
updateAcceptedCategories(['necessary', 'analytics']);
// Re-seed _ga after the first sweep would have left it (analytics consented).
const nativeSet = Object.getOwnPropertyDescriptor(
Document.prototype,
'cookie',
)?.set;
nativeSet?.call(document, '_fbp=fb.2.reseed; path=/');
// Revoke marketing, sweep again.
updateAcceptedCategories(['necessary', 'analytics']);
sweepDisallowedState();
expect(document.cookie).toContain('_ga=');
expect(document.cookie).not.toContain('_fbp=');
});
it('cleans non-consented localStorage keys', () => {
localStorage.setItem('_consentos_keep', 'yes');
// Seed via a direct setItem — the proxied setter would block
// non-necessary writes, but we want a pre-existing key.
const nativeSetItem = Object.getPrototypeOf(localStorage).setItem;
nativeSetItem.call(localStorage, '_ga_stuff', 'tracker');
nativeSetItem.call(localStorage, 'opaque_key', 'leave-alone');
updateAcceptedCategories(['necessary']);
expect(localStorage.getItem('_consentos_keep')).toBe('yes');
expect(localStorage.getItem('_ga_stuff')).toBeNull();
expect(localStorage.getItem('opaque_key')).toBe('leave-alone');
localStorage.clear();
});
});
});