Analyse: Client prüft Einwilligung vor dem Senden, Fassung der Banner-Wahl, fetch ohne Cookies

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 18:50:26 +00:00
1 parent c2cbc4156f
commit 8d64a18d35
5 files changed
+342 -119

No files matched your search

+7 -1
View File
@@ -3,6 +3,11 @@
export const CONSENT_KEY = 'wk-consent'; export const CONSENT_KEY = 'wk-consent';
export const CONSENT_MAX_AGE = 183 * 86400000; export const CONSENT_MAX_AGE = 183 * 86400000;
export const BANNER_VERSION = '2026-10-02';
export const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
export const BANNER_LINK_LABEL = 'Datenschutz';
export const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
export const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
export function vpClass(width) { export function vpClass(width) {
const w = Number(width); const w = Number(width);
@@ -89,9 +94,10 @@ export function readConsent(storage, now) {
if (!raw) return null; if (!raw) return null;
const c = JSON.parse(raw); const c = JSON.parse(raw);
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null; if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen
const age = now - c.t; const age = now - c.t;
if (age < 0 || age >= CONSENT_MAX_AGE) return null; if (age < 0 || age >= CONSENT_MAX_AGE) return null;
return { v: 1, a: c.a, t: c.t }; return { v: 1, a: c.a, t: c.t, bv: c.bv };
} catch (e) { return null; } } catch (e) { return null; }
} }
+7 -1
View File
@@ -6,6 +6,11 @@
const CONSENT_KEY = 'wk-consent'; const CONSENT_KEY = 'wk-consent';
const CONSENT_MAX_AGE = 183 * 86400000; const CONSENT_MAX_AGE = 183 * 86400000;
const BANNER_VERSION = '2026-10-02';
const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
const BANNER_LINK_LABEL = 'Datenschutz';
const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
function vpClass(width) { function vpClass(width) {
const w = Number(width); const w = Number(width);
@@ -92,9 +97,10 @@ function readConsent(storage, now) {
if (!raw) return null; if (!raw) return null;
const c = JSON.parse(raw); const c = JSON.parse(raw);
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null; if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen
const age = now - c.t; const age = now - c.t;
if (age < 0 || age >= CONSENT_MAX_AGE) return null; if (age < 0 || age >= CONSENT_MAX_AGE) return null;
return { v: 1, a: c.a, t: c.t }; return { v: 1, a: c.a, t: c.t, bv: c.bv };
} catch (e) { return null; } } catch (e) { return null; }
} }
+74 -27
View File
@@ -6,6 +6,11 @@
const CONSENT_KEY = 'wk-consent'; const CONSENT_KEY = 'wk-consent';
const CONSENT_MAX_AGE = 183 * 86400000; const CONSENT_MAX_AGE = 183 * 86400000;
const BANNER_VERSION = '2026-10-02';
const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
const BANNER_LINK_LABEL = 'Datenschutz';
const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
function vpClass(width) { function vpClass(width) {
const w = Number(width); const w = Number(width);
@@ -92,9 +97,10 @@ function readConsent(storage, now) {
if (!raw) return null; if (!raw) return null;
const c = JSON.parse(raw); const c = JSON.parse(raw);
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null; if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen
const age = now - c.t; const age = now - c.t;
if (age < 0 || age >= CONSENT_MAX_AGE) return null; if (age < 0 || age >= CONSENT_MAX_AGE) return null;
return { v: 1, a: c.a, t: c.t }; return { v: 1, a: c.a, t: c.t, bv: c.bv };
} catch (e) { return null; } } catch (e) { return null; }
} }
@@ -113,7 +119,8 @@ function escapeHtml(s) {
} }
// WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites. // WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites.
// Keine Cookies, keine Kennung im Browser. Nur pv ohne Einwilligung; ck/ct/en nur nach Annehmen. // Keine Cookies, keine Kennung im Browser. Ohne Wahl nur pv {t,site,path}; ref/vp/cx/ck/ct/en nur nach Annehmen;
// nach Ablehnen oder bei GPC/DNT geht nichts mehr raus.
function main() { function main() {
const DOC = document; const DOC = document;
@@ -149,7 +156,6 @@ function main() {
function send(obj) { function send(obj) {
try { try {
const body = JSON.stringify(obj); const body = JSON.stringify(obj);
if (navigator.sendBeacon && navigator.sendBeacon(endpoint, body)) return;
fetch(endpoint, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } }).catch(function () {}); fetch(endpoint, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } }).catch(function () {});
} catch (e) { /* egal */ } } catch (e) { /* egal */ }
} }
@@ -157,37 +163,71 @@ function main() {
const vp = () => vpClass(WIN.innerWidth); const vp = () => vpClass(WIN.innerWidth);
const ownHost = () => WIN.location.hostname; const ownHost = () => WIN.location.hostname;
// ---- Seitenaufruf (pv): immer, auch ohne Einwilligung ---- // ---- Widerspruch (GPC/DNT) und Wahl: vor jedem Senden und vor jedem Hook ----
let storage = null;
try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
const dnt = (v) => v === '1' || v === 'yes';
const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
if (optOut) return; // wie Ablehnen: nichts senden, kein Banner, keine Hooks
const stored = readConsent(storage, Date.now());
let memChoice = null;
let tracking = false;
// Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt)
function choiceNow() {
const c = readConsent(storage, Date.now());
return c ? c.a : (memChoice ? memChoice.a : null);
}
const counting = () => choiceNow() !== 'reject';
// ---- Seitenaufruf (pv): ohne Wahl nur Seitenadresse, ref/vp nur nach Annehmen ----
let lastPath = null; let lastPath = null;
let pvStart = Date.now(); let pvStart = Date.now();
let maxScroll = 0; let maxScroll = 0;
let enSent = false; let enSent = false;
let pvSent = false;
let cxSent = false;
function sendPv(first) { function sendPv(first) {
if (!counting()) return;
lastPath = cleanPath(WIN.location.pathname); lastPath = cleanPath(WIN.location.pathname);
pvStart = Date.now(); maxScroll = 0; enSent = false; pvStart = Date.now(); maxScroll = 0; enSent = false;
send({ t: 'pv', site, path: lastPath, ref: first ? refHost(DOC.referrer, ownHost()) : '', vp: vp() }); const ev = { t: 'pv', site, path: lastPath };
if (choiceNow() === 'accept') {
if (first) { ev.ref = refHost(DOC.referrer, ownHost()); cxSent = true; }
ev.vp = vp();
}
pvSent = true;
send(ev);
} }
let navTimer = null; let navTimer = null;
function scheduleNav() { function scheduleNav() {
if (navTimer) return; if (navTimer || !counting()) return;
navTimer = setTimeout(safe(function () { navTimer = setTimeout(safe(function () {
navTimer = null; navTimer = null;
if (cleanPath(WIN.location.pathname) !== lastPath) sendPv(false); if (counting() && cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
}), 300); }), 300);
} }
sendPv(true); let hooksOn = false;
function ensureHooks() {
if (hooksOn) return;
hooksOn = true;
WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); })); WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); }));
try { try {
const orig = WIN.history.pushState; const orig = WIN.history.pushState;
WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; }; WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; };
WIN.addEventListener('popstate', safe(scheduleNav)); WIN.addEventListener('popstate', safe(scheduleNav));
} catch (e) { /* egal */ } } catch (e) { /* egal */ }
}
// Nach "Annehmen": Herkunft und Fenstergroesse einmal nachreichen, oder (noch kein pv) pv mit Daten senden
function onAccept() {
ensureHooks();
if (!pvSent) { sendPv(true); return; }
if (cxSent) return;
cxSent = true;
send({ t: 'cx', site, path: lastPath, ref: refHost(DOC.referrer, ownHost()), vp: vp() });
}
if (choiceNow() !== 'reject') { sendPv(true); ensureHooks(); }
// ---- Tracking (nur nach Annehmen erreichbar) ---- // ---- Tracking (nur nach Annehmen erreichbar) ----
let storage = null;
try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
let memChoice = null;
let tracking = false;
const hosts = []; const hosts = [];
const scrollPct = () => { const scrollPct = () => {
const h = DOC.documentElement.scrollHeight || 1; const h = DOC.documentElement.scrollHeight || 1;
@@ -239,8 +279,7 @@ function main() {
// Einwilligung unmittelbar vor jedem Senden pruefen (z.B. in einem anderen Tab widerrufen) // Einwilligung unmittelbar vor jedem Senden pruefen (z.B. in einem anderen Tab widerrufen)
function allowed() { function allowed() {
const c = readConsent(storage, Date.now()); const ok = choiceNow() === 'accept';
const ok = c ? c.a === 'accept' : !!(memChoice && memChoice.a === 'accept');
if (!ok && tracking) stopTracking(); if (!ok && tracking) stopTracking();
return ok; return ok;
} }
@@ -264,19 +303,14 @@ function main() {
} }
// ---- Einwilligung ---- // ---- Einwilligung ----
const dnt = (v) => v === '1' || v === 'yes';
const gpc = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
if (gpc) return; // wie Ablehnen: kein Banner, kein bn, nie Tracking
const stored = readConsent(storage, Date.now());
let bannerHost = null; let bannerHost = null;
let linkHostEl = null; let linkHostEl = null;
function choose(a) { function choose(a) {
memChoice = null; memChoice = null;
try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now() })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ } try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: BANNER_VERSION })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ }
send({ t: 'bn', site, a }); send({ t: 'bn', site, a });
if (a === 'accept') startTracking(); else stopTracking(); if (a === 'accept') { startTracking(); onAccept(); } else stopTracking();
closeBanner(); closeBanner();
} }
@@ -297,6 +331,13 @@ function main() {
'button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' + 'button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' +
'@media (max-width:767px){.bar{display:block;margin:8px;padding:16px;border:1px solid #d0d0d0;border-radius:12px}.r{flex-direction:column;margin-top:12px}button{width:100%}}'; '@media (max-width:767px){.bar{display:block;margin:8px;padding:16px;border:1px solid #d0d0d0;border-radius:12px}.r{flex-direction:column;margin-top:12px}button{width:100%}}';
let prevPad = '';
function applyPad() {
if (!bannerHost) return;
const h = Math.ceil(bannerHost.getBoundingClientRect().height || bannerHost.offsetHeight || 0);
DOC.body.style.paddingBottom = h + 'px';
}
const onResize = safe(applyPad);
function openBanner(focus) { function openBanner(focus) {
if (bannerHost) return; if (bannerHost) return;
const prev = DOC.activeElement; const prev = DOC.activeElement;
@@ -306,9 +347,9 @@ function main() {
hosts.push(bannerHost); hosts.push(bannerHost);
const root = bannerHost.attachShadow({ mode: 'open' }); const root = bannerHost.attachShadow({ mode: 'open' });
root.appendChild(el('style', {}, CSS)); root.appendChild(el('style', {}, CSS));
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': 'Cookie-Einstellungen' }); const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA });
const p = el('p', { class: 't' }, 'Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: '); const p = el('p', { class: 't' }, BANNER_TEXT);
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, 'Datenschutz')); p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL));
const row = el('div', { class: 'r' }); const row = el('div', { class: 'r' });
const no = el('button', { type: 'button' }, 'Ablehnen'); const no = el('button', { type: 'button' }, 'Ablehnen');
const yes = el('button', { type: 'button' }, 'Annehmen'); const yes = el('button', { type: 'button' }, 'Annehmen');
@@ -318,6 +359,10 @@ function main() {
bar.appendChild(p); bar.appendChild(row); bar.appendChild(p); bar.appendChild(row);
root.appendChild(bar); root.appendChild(bar);
DOC.body.appendChild(bannerHost); DOC.body.appendChild(bannerHost);
// Platz unten schaffen, damit Footer-Links (Impressum, Datenschutz) erreichbar bleiben
prevPad = DOC.body.style.paddingBottom;
applyPad();
WIN.addEventListener('resize', onResize);
if (linkHostEl) linkHostEl.style.display = 'none'; if (linkHostEl) linkHostEl.style.display = 'none';
if (focus === true) no.focus(); if (focus === true) no.focus();
} }
@@ -327,6 +372,8 @@ function main() {
if (i !== -1) hosts.splice(i, 1); if (i !== -1) hosts.splice(i, 1);
bannerHost.remove(); bannerHost.remove();
bannerHost = null; bannerHost = null;
WIN.removeEventListener('resize', onResize);
DOC.body.style.paddingBottom = prevPad;
if (linkHostEl) linkHostEl.style.display = ''; if (linkHostEl) linkHostEl.style.display = '';
} }
@@ -336,8 +383,8 @@ function main() {
st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646'; st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646';
hosts.push(linkHostEl); hosts.push(linkHostEl);
const root = linkHostEl.attachShadow({ mode: 'open' }); const root = linkHostEl.attachShadow({ mode: 'open' });
root.appendChild(el('style', {}, ':host{all:initial}button{font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:6px 10px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}')); root.appendChild(el('style', {}, ':host{all:initial}button{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px;line-height:1;min-width:24px;min-height:32px;padding:8px 12px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}'));
const b = el('button', { type: 'button' }, 'Cookie-Einstellungen'); const b = el('button', { type: 'button' }, SETTINGS_LABEL);
b.addEventListener('click', safe(function () { openBanner(true); })); b.addEventListener('click', safe(function () { openBanner(true); }));
root.appendChild(b); root.appendChild(b);
DOC.body.appendChild(linkHostEl); DOC.body.appendChild(linkHostEl);
@@ -353,7 +400,7 @@ function main() {
WIN.addEventListener('storage', safe(function (e) { WIN.addEventListener('storage', safe(function (e) {
if (e.key !== null && e.key !== CONSENT_KEY) return; if (e.key !== null && e.key !== CONSENT_KEY) return;
const c = readConsent(storage, Date.now()); const c = readConsent(storage, Date.now());
if (c && c.a === 'accept') startTracking(); else stopTracking(); if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking();
if (c) closeBanner(); if (c) closeBanner();
})); }));
+68 -27
View File
@@ -1,6 +1,7 @@
// WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites. // WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites.
// Keine Cookies, keine Kennung im Browser. Nur pv ohne Einwilligung; ck/ct/en nur nach Annehmen. // Keine Cookies, keine Kennung im Browser. Ohne Wahl nur pv {t,site,path}; ref/vp/cx/ck/ct/en nur nach Annehmen;
import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, CONSENT_KEY } from './core.js'; // nach Ablehnen oder bei GPC/DNT geht nichts mehr raus.
import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, CONSENT_KEY, BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from './core.js';
function main() { function main() {
const DOC = document; const DOC = document;
@@ -36,7 +37,6 @@ function main() {
function send(obj) { function send(obj) {
try { try {
const body = JSON.stringify(obj); const body = JSON.stringify(obj);
if (navigator.sendBeacon && navigator.sendBeacon(endpoint, body)) return;
fetch(endpoint, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } }).catch(function () {}); fetch(endpoint, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } }).catch(function () {});
} catch (e) { /* egal */ } } catch (e) { /* egal */ }
} }
@@ -44,37 +44,71 @@ function main() {
const vp = () => vpClass(WIN.innerWidth); const vp = () => vpClass(WIN.innerWidth);
const ownHost = () => WIN.location.hostname; const ownHost = () => WIN.location.hostname;
// ---- Seitenaufruf (pv): immer, auch ohne Einwilligung ---- // ---- Widerspruch (GPC/DNT) und Wahl: vor jedem Senden und vor jedem Hook ----
let storage = null;
try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
const dnt = (v) => v === '1' || v === 'yes';
const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
if (optOut) return; // wie Ablehnen: nichts senden, kein Banner, keine Hooks
const stored = readConsent(storage, Date.now());
let memChoice = null;
let tracking = false;
// Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt)
function choiceNow() {
const c = readConsent(storage, Date.now());
return c ? c.a : (memChoice ? memChoice.a : null);
}
const counting = () => choiceNow() !== 'reject';
// ---- Seitenaufruf (pv): ohne Wahl nur Seitenadresse, ref/vp nur nach Annehmen ----
let lastPath = null; let lastPath = null;
let pvStart = Date.now(); let pvStart = Date.now();
let maxScroll = 0; let maxScroll = 0;
let enSent = false; let enSent = false;
let pvSent = false;
let cxSent = false;
function sendPv(first) { function sendPv(first) {
if (!counting()) return;
lastPath = cleanPath(WIN.location.pathname); lastPath = cleanPath(WIN.location.pathname);
pvStart = Date.now(); maxScroll = 0; enSent = false; pvStart = Date.now(); maxScroll = 0; enSent = false;
send({ t: 'pv', site, path: lastPath, ref: first ? refHost(DOC.referrer, ownHost()) : '', vp: vp() }); const ev = { t: 'pv', site, path: lastPath };
if (choiceNow() === 'accept') {
if (first) { ev.ref = refHost(DOC.referrer, ownHost()); cxSent = true; }
ev.vp = vp();
}
pvSent = true;
send(ev);
} }
let navTimer = null; let navTimer = null;
function scheduleNav() { function scheduleNav() {
if (navTimer) return; if (navTimer || !counting()) return;
navTimer = setTimeout(safe(function () { navTimer = setTimeout(safe(function () {
navTimer = null; navTimer = null;
if (cleanPath(WIN.location.pathname) !== lastPath) sendPv(false); if (counting() && cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
}), 300); }), 300);
} }
sendPv(true); let hooksOn = false;
function ensureHooks() {
if (hooksOn) return;
hooksOn = true;
WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); })); WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); }));
try { try {
const orig = WIN.history.pushState; const orig = WIN.history.pushState;
WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; }; WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; };
WIN.addEventListener('popstate', safe(scheduleNav)); WIN.addEventListener('popstate', safe(scheduleNav));
} catch (e) { /* egal */ } } catch (e) { /* egal */ }
}
// Nach "Annehmen": Herkunft und Fenstergroesse einmal nachreichen, oder (noch kein pv) pv mit Daten senden
function onAccept() {
ensureHooks();
if (!pvSent) { sendPv(true); return; }
if (cxSent) return;
cxSent = true;
send({ t: 'cx', site, path: lastPath, ref: refHost(DOC.referrer, ownHost()), vp: vp() });
}
if (choiceNow() !== 'reject') { sendPv(true); ensureHooks(); }
// ---- Tracking (nur nach Annehmen erreichbar) ---- // ---- Tracking (nur nach Annehmen erreichbar) ----
let storage = null;
try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
let memChoice = null;
let tracking = false;
const hosts = []; const hosts = [];
const scrollPct = () => { const scrollPct = () => {
const h = DOC.documentElement.scrollHeight || 1; const h = DOC.documentElement.scrollHeight || 1;
@@ -126,8 +160,7 @@ function main() {
// Einwilligung unmittelbar vor jedem Senden pruefen (z.B. in einem anderen Tab widerrufen) // Einwilligung unmittelbar vor jedem Senden pruefen (z.B. in einem anderen Tab widerrufen)
function allowed() { function allowed() {
const c = readConsent(storage, Date.now()); const ok = choiceNow() === 'accept';
const ok = c ? c.a === 'accept' : !!(memChoice && memChoice.a === 'accept');
if (!ok && tracking) stopTracking(); if (!ok && tracking) stopTracking();
return ok; return ok;
} }
@@ -151,19 +184,14 @@ function main() {
} }
// ---- Einwilligung ---- // ---- Einwilligung ----
const dnt = (v) => v === '1' || v === 'yes';
const gpc = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
if (gpc) return; // wie Ablehnen: kein Banner, kein bn, nie Tracking
const stored = readConsent(storage, Date.now());
let bannerHost = null; let bannerHost = null;
let linkHostEl = null; let linkHostEl = null;
function choose(a) { function choose(a) {
memChoice = null; memChoice = null;
try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now() })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ } try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: BANNER_VERSION })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ }
send({ t: 'bn', site, a }); send({ t: 'bn', site, a });
if (a === 'accept') startTracking(); else stopTracking(); if (a === 'accept') { startTracking(); onAccept(); } else stopTracking();
closeBanner(); closeBanner();
} }
@@ -184,6 +212,13 @@ function main() {
'button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' + 'button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' +
'@media (max-width:767px){.bar{display:block;margin:8px;padding:16px;border:1px solid #d0d0d0;border-radius:12px}.r{flex-direction:column;margin-top:12px}button{width:100%}}'; '@media (max-width:767px){.bar{display:block;margin:8px;padding:16px;border:1px solid #d0d0d0;border-radius:12px}.r{flex-direction:column;margin-top:12px}button{width:100%}}';
let prevPad = '';
function applyPad() {
if (!bannerHost) return;
const h = Math.ceil(bannerHost.getBoundingClientRect().height || bannerHost.offsetHeight || 0);
DOC.body.style.paddingBottom = h + 'px';
}
const onResize = safe(applyPad);
function openBanner(focus) { function openBanner(focus) {
if (bannerHost) return; if (bannerHost) return;
const prev = DOC.activeElement; const prev = DOC.activeElement;
@@ -193,9 +228,9 @@ function main() {
hosts.push(bannerHost); hosts.push(bannerHost);
const root = bannerHost.attachShadow({ mode: 'open' }); const root = bannerHost.attachShadow({ mode: 'open' });
root.appendChild(el('style', {}, CSS)); root.appendChild(el('style', {}, CSS));
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': 'Cookie-Einstellungen' }); const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA });
const p = el('p', { class: 't' }, 'Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: '); const p = el('p', { class: 't' }, BANNER_TEXT);
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, 'Datenschutz')); p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL));
const row = el('div', { class: 'r' }); const row = el('div', { class: 'r' });
const no = el('button', { type: 'button' }, 'Ablehnen'); const no = el('button', { type: 'button' }, 'Ablehnen');
const yes = el('button', { type: 'button' }, 'Annehmen'); const yes = el('button', { type: 'button' }, 'Annehmen');
@@ -205,6 +240,10 @@ function main() {
bar.appendChild(p); bar.appendChild(row); bar.appendChild(p); bar.appendChild(row);
root.appendChild(bar); root.appendChild(bar);
DOC.body.appendChild(bannerHost); DOC.body.appendChild(bannerHost);
// Platz unten schaffen, damit Footer-Links (Impressum, Datenschutz) erreichbar bleiben
prevPad = DOC.body.style.paddingBottom;
applyPad();
WIN.addEventListener('resize', onResize);
if (linkHostEl) linkHostEl.style.display = 'none'; if (linkHostEl) linkHostEl.style.display = 'none';
if (focus === true) no.focus(); if (focus === true) no.focus();
} }
@@ -214,6 +253,8 @@ function main() {
if (i !== -1) hosts.splice(i, 1); if (i !== -1) hosts.splice(i, 1);
bannerHost.remove(); bannerHost.remove();
bannerHost = null; bannerHost = null;
WIN.removeEventListener('resize', onResize);
DOC.body.style.paddingBottom = prevPad;
if (linkHostEl) linkHostEl.style.display = ''; if (linkHostEl) linkHostEl.style.display = '';
} }
@@ -223,8 +264,8 @@ function main() {
st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646'; st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646';
hosts.push(linkHostEl); hosts.push(linkHostEl);
const root = linkHostEl.attachShadow({ mode: 'open' }); const root = linkHostEl.attachShadow({ mode: 'open' });
root.appendChild(el('style', {}, ':host{all:initial}button{font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:6px 10px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}')); root.appendChild(el('style', {}, ':host{all:initial}button{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px;line-height:1;min-width:24px;min-height:32px;padding:8px 12px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}'));
const b = el('button', { type: 'button' }, 'Cookie-Einstellungen'); const b = el('button', { type: 'button' }, SETTINGS_LABEL);
b.addEventListener('click', safe(function () { openBanner(true); })); b.addEventListener('click', safe(function () { openBanner(true); }));
root.appendChild(b); root.appendChild(b);
DOC.body.appendChild(linkHostEl); DOC.body.appendChild(linkHostEl);
@@ -240,7 +281,7 @@ function main() {
WIN.addEventListener('storage', safe(function (e) { WIN.addEventListener('storage', safe(function (e) {
if (e.key !== null && e.key !== CONSENT_KEY) return; if (e.key !== null && e.key !== CONSENT_KEY) return;
const c = readConsent(storage, Date.now()); const c = readConsent(storage, Date.now());
if (c && c.a === 'accept') startTracking(); else stopTracking(); if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking();
if (c) closeBanner(); if (c) closeBanner();
})); }));
+186 -63
View File
@@ -7,8 +7,10 @@ import { dirname, join } from 'node:path';
import { import {
vpClass, cleanPath, refHost, cssPath, contactKind, readConsent, contrastOk, escapeHtml, vpClass, cleanPath, refHost, cssPath, contactKind, readConsent, contrastOk, escapeHtml,
} from '../client/core.js'; } from '../client/core.js';
import { BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from '../client/core.js';
import { buildAll } from '../client/build.js'; import { buildAll } from '../client/build.js';
const BV = '2026-10-02';
const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist'); const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist');
// ---------- core ---------- // ---------- core ----------
@@ -96,13 +98,17 @@ test('readConsent', () => {
const now = 1_700_000_000_000; const now = 1_700_000_000_000;
const day = 86400000; const day = 86400000;
const st = (v) => ({ getItem: () => v }); const st = (v) => ({ getItem: () => v });
assert.deepEqual(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now - 10 * day })), now), { v: 1, a: 'accept', t: now - 10 * day }); const J = (o) => st(JSON.stringify(o));
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'reject', t: now - 182 * day })), now).a, 'reject'); assert.equal(BANNER_VERSION, '2026-10-02');
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now - 183 * day })), now), null); assert.deepEqual(readConsent(J({ v: 1, a: 'accept', bv: BV, t: now - 10 * day }), now), { v: 1, a: 'accept', t: now - 10 * day, bv: BV });
assert.equal(readConsent(st(JSON.stringify({ v: 2, a: 'accept', t: now })), now), null); assert.equal(readConsent(J({ v: 1, a: 'reject', bv: BV, t: now - 182 * day }), now).a, 'reject');
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'maybe', t: now })), now), null); assert.equal(readConsent(J({ v: 1, a: 'accept', bv: BV, t: now - 183 * day }), now), null);
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: 'x' })), now), null); assert.equal(readConsent(J({ v: 1, a: 'accept', t: now }), now), null, 'ohne bv');
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now + day })), now), null); assert.equal(readConsent(J({ v: 1, a: 'accept', bv: '2026-01-01', t: now }), now), null, 'alte Fassung');
assert.equal(readConsent(J({ v: 2, a: 'accept', bv: BV, t: now }), now), null);
assert.equal(readConsent(J({ v: 1, a: 'maybe', bv: BV, t: now }), now), null);
assert.equal(readConsent(J({ v: 1, a: 'accept', bv: BV, t: 'x' }), now), null);
assert.equal(readConsent(J({ v: 1, a: 'accept', bv: BV, t: now + day }), now), null);
assert.equal(readConsent(st('kaputt{'), now), null); assert.equal(readConsent(st('kaputt{'), now), null);
assert.equal(readConsent(st(null), now), null); assert.equal(readConsent(st(null), now), null);
assert.equal(readConsent({ getItem() { throw new Error('blocked'); } }, now), null); assert.equal(readConsent({ getItem() { throw new Error('blocked'); } }, now), null);
@@ -144,6 +150,7 @@ function makeEl(tag) {
addEventListener(t, f) { (this.listeners[t] ||= []).push(f); }, addEventListener(t, f) { (this.listeners[t] ||= []).push(f); },
removeEventListener(t, f) { this.listeners[t] = (this.listeners[t] || []).filter((x) => x !== f); }, removeEventListener(t, f) { this.listeners[t] = (this.listeners[t] || []).filter((x) => x !== f); },
focus() { this.focused = true; }, focus() { this.focused = true; },
getBoundingClientRect() { return { height: this.fakeHeight || 0 }; },
remove() { this.removed = true; if (this.parentNode) this.parentNode.children = this.parentNode.children.filter((c) => c !== this); }, remove() { this.removed = true; if (this.parentNode) this.parentNode.children = this.parentNode.children.filter((c) => c !== this); },
closest(sel) { closest(sel) {
const attr = /^\[([\w-]+)\]$/.exec(sel); const attr = /^\[([\w-]+)\]$/.exec(sel);
@@ -162,10 +169,10 @@ function walk(n, fn) { fn(n); for (const c of n.children || []) walk(c, fn); if
function findAll(root, pred) { const out = []; walk(root, (n) => { if (pred(n)) out.push(n); }); return out; } function findAll(root, pred) { const out = []; walk(root, (n) => { if (pred(n)) out.push(n); }); return out; }
const btn = (root, label) => findAll(root, (n) => n.tagName === 'BUTTON' && n.textContent === label)[0]; const btn = (root, label) => findAll(root, (n) => n.tagName === 'BUTTON' && n.textContent === label)[0];
function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false } = {}) { function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false, bannerHeight = 0, bodyPad } = {}) {
const sent = []; const store = {}; let now = 1_700_000_000_000; const sent = []; const reads = { referrer: 0, innerWidth: 0 }; const beacon = []; const fetches = []; const store = {}; let now = 1_700_000_000_000;
if (stored) store['wk-consent'] = JSON.stringify(stored); if (stored) store['wk-consent'] = JSON.stringify(stored);
const body = makeEl('body'); const html = makeEl('html'); const body = makeEl('body'); if (bodyPad !== undefined) body.style.paddingBottom = bodyPad; const html = makeEl('html');
const timers = []; const timers = [];
const script = makeEl('script'); script.setAttribute('src', src); script.setAttribute('data-site', 'abcdef0123456789abcd'); script.setAttribute('data-color', '#0a5a8a'); script.src = src; const script = makeEl('script'); script.setAttribute('src', src); script.setAttribute('data-site', 'abcdef0123456789abcd'); script.setAttribute('data-color', '#0a5a8a'); script.src = src;
const docL = {}; const winL = {}; const docL = {}; const winL = {};
@@ -173,28 +180,29 @@ function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar
const injected = []; const injected = [];
if (link) link.click = () => { for (const f of [...(docL.click || [])]) f({ type: 'click', target: link, preventDefault() {} }); }; if (link) link.click = () => { for (const f of [...(docL.click || [])]) f({ type: 'click', target: link, preventDefault() {} }); };
const document = { const document = {
currentScript: script, referrer: 'https://www.google.com/', visibilityState: 'visible', currentScript: script, get referrer() { reads.referrer++; return 'https://www.google.com/'; }, visibilityState: 'visible',
documentElement: Object.assign(html, { scrollWidth: 1000, scrollHeight: 2000 }), body, head: makeEl('head'), documentElement: Object.assign(html, { scrollWidth: 1000, scrollHeight: 2000 }), body, head: makeEl('head'),
createElement: makeEl, activeElement: null, createElement(t) { const e = makeEl(t); if (bannerHeight) e.offsetHeight = bannerHeight; return e; }, activeElement: null,
querySelector(s) { return s === '[data-webklar-consent]' ? link : null; }, querySelector(s) { return s === '[data-webklar-consent]' ? link : null; },
addEventListener(t, f) { (docL[t] ||= []).push(f); }, addEventListener(t, f) { (docL[t] ||= []).push(f); },
removeEventListener(t, f) { docL[t] = (docL[t] || []).filter((x) => x !== f); }, removeEventListener(t, f) { docL[t] = (docL[t] || []).filter((x) => x !== f); },
}; };
body.appendChild = function (c) { c.parentNode = this; this.children.push(c); if (c.attrs.src) injected.push(c); return c; }; body.appendChild = function (c) { c.parentNode = this; this.children.push(c); if (c.attrs.src) injected.push(c); return c; };
document.head.appendChild = body.appendChild; document.head.appendChild = body.appendChild;
const history = { pushState() {}, replaceState(s, t, u) { this.replaced = u; } }; const origPush = function () {};
const history = { pushState: origPush, replaceState(s, t, u) { this.replaced = u; } };
const win = { const win = {
innerWidth: 1200, innerHeight: 800, scrollX: 0, scrollY: 300, document, history, get innerWidth() { reads.innerWidth++; return 1200; }, innerHeight: 800, scrollX: 0, scrollY: 300, document, history,
location: { hostname: 'mein.de', origin: 'https://mein.de', pathname: '/start', search, href: 'https://mein.de/start' + search, hash: '' }, location: { hostname: 'mein.de', origin: 'https://mein.de', pathname: '/start', search, href: 'https://mein.de/start' + search, hash: '' },
addEventListener(t, f) { (winL[t] ||= []).push(f); }, addEventListener(t, f) { (winL[t] ||= []).push(f); },
removeEventListener(t, f) { winL[t] = (winL[t] || []).filter((x) => x !== f); }, removeEventListener(t, f) { winL[t] = (winL[t] || []).filter((x) => x !== f); },
}; };
const sandbox = { const sandbox = {
window: win, document, history, location: win.location, URL, URLSearchParams, JSON, Math, Date: { now: () => now }, String, Number, Array, Object, RegExp, Error, parseInt, window: win, document, history, location: win.location, URL, URLSearchParams, JSON, Math, Date: { now: () => now }, String, Number, Array, Object, RegExp, Error, parseInt,
navigator: { globalPrivacyControl: gpc, doNotTrack: dnt, sendBeacon(u, d) { sent.push({ u, d: JSON.parse(d) }); return true; } }, navigator: { globalPrivacyControl: gpc, doNotTrack: dnt, sendBeacon(u, d) { beacon.push({ u, d }); return true; } },
localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } }, localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },
setTimeout: (f, ms) => { timers.push(f); return timers.length; }, clearTimeout() {}, setTimeout: (f, ms) => { timers.push(f); return timers.length; }, clearTimeout() {},
setInterval: () => 0, clearInterval() {}, Blob: class {}, fetch: () => Promise.resolve(), setInterval: () => 0, clearInterval() {}, Blob: class {}, fetch: (u, o) => { fetches.push({ u, o }); sent.push({ u, d: JSON.parse(o.body) }); return Promise.resolve(); },
}; };
win.localStorage = sandbox.localStorage; win.localStorage = sandbox.localStorage;
sandbox.globalThis = sandbox; sandbox.globalThis = sandbox;
@@ -205,31 +213,46 @@ function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar
const leave = () => { for (const f of [...(winL.pagehide || [])]) f({}); document.visibilityState = 'hidden'; for (const f of [...(docL.visibilitychange || [])]) f({}); }; const leave = () => { for (const f of [...(winL.pagehide || [])]) f({}); document.visibilityState = 'hidden'; for (const f of [...(docL.visibilitychange || [])]) f({}); };
const rerun = () => vm.runInContext(readFileSync(join(DIST, 'w.js'), 'utf8'), sandbox); const rerun = () => vm.runInContext(readFileSync(join(DIST, 'w.js'), 'utf8'), sandbox);
const storage = () => (winL.storage || []).forEach((f) => f({ key: 'wk-consent' })); const storage = () => (winL.storage || []).forEach((f) => f({ key: 'wk-consent' }));
return { rerun, storage, sent, store, types, ck, leave, body, docL, winL, win, injected, history, timers, setNow: (n) => { now = n; }, link, document }; const nav = (p) => { win.location.pathname = p; history.pushState(null, '', p); timers.splice(0).forEach((f) => f()); };
return { nav, reads, beacon, fetches, origPush, rerun, storage, sent, store, types, ck, leave, body, docL, winL, win, injected, history, timers, setNow: (n) => { now = n; }, link, document };
} }
const target = () => { const b = makeEl('button'); b.parentNode = makeEl('body'); b.attrs.class = 'cta'; return b; }; const target = () => { const b = makeEl('button'); b.parentNode = makeEl('body'); b.attrs.class = 'cta'; return b; };
const hostEl = (h) => h.body.children.find((c) => c.shadow && findAll(c.shadow, (n) => n.attrs && n.attrs.role === 'dialog').length); const hostEl = (h) => h.body.children.find((c) => c.shadow && findAll(c.shadow, (n) => n.attrs && n.attrs.role === 'dialog').length);
test('w.js: ohne Einwilligung nur pv + bn shown, keine ck/ct/en', () => { const SITE = 'abcdef0123456789abcd';
const ACC0 = { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 };
test('w.js: ohne Einwilligung nur pv {t,site,path} + bn shown, kein Lesen von referrer/innerWidth', () => {
const h = harness(); const h = harness();
assert.deepEqual(h.types(), ['pv', 'bn:shown']); assert.deepEqual(h.types(), ['pv', 'bn:shown']);
assert.equal(h.sent[0].u, 'https://project.webklar.com/a/e'); assert.equal(h.sent[0].u, 'https://project.webklar.com/a/e');
assert.deepEqual(h.sent[0].d, { t: 'pv', site: 'abcdef0123456789abcd', path: '/start', ref: 'www.google.com', vp: 'd' }); assert.deepEqual(h.sent[0].d, { t: 'pv', site: SITE, path: '/start' });
assert.deepEqual(h.sent[1].d, { t: 'bn', site: 'abcdef0123456789abcd', a: 'shown' }); assert.deepEqual(Object.keys(h.sent[0].d).sort(), ['path', 'site', 't']);
assert.deepEqual(h.sent[1].d, { t: 'bn', site: SITE, a: 'shown' });
assert.equal(h.reads.referrer, 0);
assert.equal(h.reads.innerWidth, 0);
h.ck(target()); h.leave(); h.ck(target()); h.leave();
assert.deepEqual(h.types(), ['pv', 'bn:shown']); assert.deepEqual(h.types(), ['pv', 'bn:shown']);
assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation
}); });
test('core: Banner-Konstanten', () => {
assert.match(BANNER_TEXT, /^Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung\./);
assert.match(BANNER_TEXT, /Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast\./);
assert.match(BANNER_TEXT, /über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen\. Details: $/);
assert.equal(BANNER_LINK_LABEL, 'Datenschutz');
assert.equal(BANNER_ARIA, 'Einwilligung zur Reichweitenmessung');
assert.equal(SETTINGS_LABEL, 'Datenschutz-Einstellungen');
});
test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)', () => { test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)', () => {
const h = harness(); const h = harness();
const host = hostEl(h); const host = hostEl(h);
assert.ok(host, 'Banner im Shadow Root'); assert.ok(host, 'Banner im Shadow Root');
const dlg = findAll(host.shadow, (n) => n.attrs.role === 'dialog')[0]; const dlg = findAll(host.shadow, (n) => n.attrs.role === 'dialog')[0];
assert.equal(dlg.attrs['aria-label'], 'Cookie-Einstellungen'); assert.equal(dlg.attrs['aria-label'], BANNER_ARIA);
const all = findAll(host.shadow, () => true); const all = findAll(host.shadow, () => true);
const text = all.map((n) => n.textContent).join(' '); const text = all.map((n) => n.textContent).join(' ');
assert.match(text, /Wir möchten mit deiner Zustimmung anonym auswerten/); assert.ok(all.find((n) => n.tagName === 'P').textContent.startsWith(BANNER_TEXT), 'Text beginnt mit BANNER_TEXT');
const a = all.find((n) => n.tagName === 'A'); const a = all.find((n) => n.tagName === 'A');
assert.equal(a.attrs.href, '/datenschutz-analyse.html'); assert.equal(a.attrs.href, '/datenschutz-analyse.html');
assert.equal(a.textContent, 'Datenschutz'); assert.equal(a.textContent, 'Datenschutz');
@@ -244,7 +267,6 @@ test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)'
assert.match(css, /button\{[^}]*background:#0a5a8a[^}]*color:#fff/); assert.match(css, /button\{[^}]*background:#0a5a8a[^}]*color:#fff/);
assert.match(css, /button\{[^}]*[; ]width:150px/); assert.match(css, /button\{[^}]*[; ]width:150px/);
assert.equal((css.match(/(?<![-\w])width:/g) || []).length, 2, 'eine feste Breite fuer beide + 100% mobil'); assert.equal((css.match(/(?<![-\w])width:/g) || []).length, 2, 'eine feste Breite fuer beide + 100% mobil');
assert.ok(!/min-width/.test(css));
const portal = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'public', 'funktionen.css'), 'utf8'); const portal = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'public', 'funktionen.css'), 'utf8');
assert.match(portal, /\.an-bn-btn \{[^}]*[; ]width: 150px/); assert.match(portal, /\.an-bn-btn \{[^}]*[; ]width: 150px/);
assert.equal((portal.match(/\.an-bn-btn[^{]*\{/g) || []).length, 2, 'Basisregel + mobile Regel'); assert.equal((portal.match(/\.an-bn-btn[^{]*\{/g) || []).length, 2, 'Basisregel + mobile Regel');
@@ -258,8 +280,8 @@ test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort',
const h = harness(); const h = harness();
const bh = hostEl(h); const bh = hostEl(h);
btn(bh.shadow, 'Annehmen').click(); btn(bh.shadow, 'Annehmen').click();
assert.equal(h.types().at(-1), 'bn:accept'); assert.deepEqual(h.types(), ['pv', 'bn:shown', 'bn:accept', 'cx']);
assert.deepEqual(JSON.parse(h.store['wk-consent']), { v: 1, a: 'accept', t: 1_700_000_000_000 }); assert.deepEqual(JSON.parse(h.store['wk-consent']), { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 });
assert.ok(bh.removed && !hostEl(h)); assert.ok(bh.removed && !hostEl(h));
const t = target(); const t = target();
h.ck(t); h.ck(t);
@@ -281,7 +303,7 @@ test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort',
assert.equal(en[0].d.scroll, 55); // (300+800)/2000 assert.equal(en[0].d.scroll, 55); // (300+800)/2000
assert.equal(typeof en[0].d.sec, 'number'); assert.equal(typeof en[0].d.sec, 'number');
// Widerruf ueber Einstellungs-Link // Widerruf ueber Einstellungs-Link
const settings = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === 'Cookie-Einstellungen')[0]; const settings = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === SETTINGS_LABEL)[0];
assert.ok(settings, 'fester Link'); assert.ok(settings, 'fester Link');
settings.click(); settings.click();
assert.ok(btn(hostEl(h).shadow, 'Ablehnen').focused, 'Fokus beim Wiederoeffnen'); assert.ok(btn(hostEl(h).shadow, 'Ablehnen').focused, 'Fokus beim Wiederoeffnen');
@@ -296,7 +318,7 @@ test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort',
}); });
test('w.js: gespeicherte Einwilligung => kein Banner, kein bn, Tracking an', () => { test('w.js: gespeicherte Einwilligung => kein Banner, kein bn, Tracking an', () => {
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } }); const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
assert.deepEqual(h.types(), ['pv']); assert.deepEqual(h.types(), ['pv']);
assert.equal(hostEl(h), undefined); assert.equal(hostEl(h), undefined);
h.ck(target()); h.ck(target());
@@ -304,28 +326,132 @@ test('w.js: gespeicherte Einwilligung => kein Banner, kein bn, Tracking an', ()
}); });
test('w.js: abgelaufene/abgelehnte Einwilligung', () => { test('w.js: abgelaufene/abgelehnte Einwilligung', () => {
const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 - 5000 } }); const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 - 5000 } });
assert.deepEqual(h.types(), ['pv']); assert.deepEqual(h.types(), []);
h.ck(target()); h.leave(); h.ck(target()); h.leave();
assert.deepEqual(h.types(), ['pv']); assert.deepEqual(h.types(), []);
const h2 = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 200 * 86400000 } }); const h2 = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 200 * 86400000 } });
assert.deepEqual(h2.types(), ['pv', 'bn:shown']); assert.deepEqual(h2.types(), ['pv', 'bn:shown']);
}); });
test('w.js: GPC und DNT => nur pv, kein Banner, nie Tracking', () => { test('w.js: GPC und DNT => nichts senden, kein Banner, keine Hooks (T1)', () => {
for (const o of [{ gpc: true }, { dnt: '1' }, { gpc: true, stored: { v: 1, a: 'accept', t: 1_700_000_000_000 } }]) { for (const o of [{ gpc: true }, { dnt: '1' }, { dnt: 'yes' }, { gpc: true, stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 } }]) {
const h = harness(o); const h = harness(o);
assert.deepEqual(h.types(), ['pv']); assert.equal(h.sent.length, 0);
assert.equal(hostEl(h), undefined); assert.equal(h.winL.pageshow, undefined);
assert.equal(findAll(h.body, (x) => x.textContent === 'Cookie-Einstellungen').length, 0); assert.equal(h.winL.popstate, undefined);
h.ck(target()); h.leave(); assert.equal(h.history.pushState, h.origPush);
assert.deepEqual(h.types(), ['pv']); assert.equal(h.body.children.length, 0, 'kein Banner, kein Einstellungsknopf');
h.ck(target()); h.leave(); h.nav('/neu');
assert.equal(h.sent.length, 0);
assert.equal(h.reads.referrer + h.reads.innerWidth, 0);
} }
}); });
test('w.js: gespeichertes Ablehnen => nichts senden, nur Einstellungsknopf; spaeteres Annehmen => bn:accept, pv mit ref+vp', () => {
const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } });
assert.equal(h.sent.length, 0);
assert.equal(h.winL.pageshow, undefined);
assert.equal(h.winL.popstate, undefined);
assert.equal(h.history.pushState, h.origPush);
assert.equal(h.body.children.length, 1);
const open = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === SETTINGS_LABEL)[0];
assert.ok(open);
open.click();
assert.equal(h.sent.length, 0);
btn(hostEl(h).shadow, 'Annehmen').click();
assert.deepEqual(h.types(), ['bn:accept', 'pv']);
assert.deepEqual(h.sent[1].d, { t: 'pv', site: SITE, path: '/start', ref: 'www.google.com', vp: 'd' });
assert.ok(h.winL.pageshow && h.winL.popstate, 'Hooks nachtraeglich installiert');
h.nav('/zwei');
assert.equal(h.types().at(-1), 'pv');
assert.equal(h.sent.at(-1).d.path, '/zwei');
});
test('w.js: Ablehnen => genau ein bn:reject, danach nichts mehr (auch nicht Seitenwechsel)', () => {
const h2 = harness();
btn(hostEl(h2).shadow, 'Ablehnen').click();
assert.deepEqual(h2.types(), ['pv', 'bn:shown', 'bn:reject']);
const n = h2.sent.length;
h2.nav('/zwei'); h2.nav('/drei');
for (const f of h2.winL.pageshow || []) f({ persisted: true });
for (const f of h2.winL.popstate || []) f({});
h2.timers.splice(0).forEach((f) => f());
h2.ck(target()); h2.leave();
for (const f of h2.docL.scroll || []) f({});
assert.equal(h2.sent.length, n);
assert.equal(h2.types().filter((t) => t === 'bn:reject').length, 1);
});
test('w.js: Seitenwechsel-Timer vor dem Ablehnen sendet nichts', () => {
const h = harness();
h.win.location.pathname = '/zwei';
h.history.pushState(null, '', '/zwei'); // Timer geplant
assert.equal(h.timers.length, 1);
btn(hostEl(h).shadow, 'Ablehnen').click();
const n = h.sent.length;
h.timers.splice(0).forEach((f) => f());
assert.equal(h.sent.length, n);
});
test('w.js: Ablehnen in anderem Tab (storage) => danach kein pv beim Seitenwechsel', () => {
const h = harness();
const n = h.sent.length;
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
h.storage();
h.nav('/zwei');
for (const f of h.winL.pageshow || []) f({ persisted: true });
assert.equal(h.sent.length, n);
});
test('w.js: Annehmen auf laufender Seite => bn:accept, genau ein cx mit ref+vp; kein zweites cx', () => {
const h = harness();
assert.deepEqual(h.types(), ['pv', 'bn:shown']);
btn(hostEl(h).shadow, 'Annehmen').click();
assert.deepEqual(h.types(), ['pv', 'bn:shown', 'bn:accept', 'cx']);
assert.deepEqual(h.sent.at(-1).d, { t: 'cx', site: SITE, path: '/start', ref: refHost('https://www.google.com/', 'mein.de'), vp: 'd' });
// Widerruf, erneut annehmen
findAll(h.body, (x) => x.textContent === SETTINGS_LABEL)[0].click();
btn(hostEl(h).shadow, 'Ablehnen').click();
findAll(h.body, (x) => x.textContent === SETTINGS_LABEL)[0].click();
btn(hostEl(h).shadow, 'Annehmen').click();
assert.equal(h.sent.filter((s) => s.d.t === 'cx').length, 1);
});
test('w.js: gespeichertes Annehmen => erstes pv mit ref+vp, kein cx', () => {
const h = harness({ stored: ACC0 });
assert.deepEqual(h.types(), ['pv']);
assert.deepEqual(h.sent[0].d, { t: 'pv', site: SITE, path: '/start', ref: 'www.google.com', vp: 'd' });
});
test('w.js: T15 kein sendBeacon, fetch mit keepalive, omit, POST, text/plain', () => {
const h = harness();
assert.ok(h.fetches.length >= 1);
assert.equal(h.beacon.length, 0);
for (const f of h.fetches) {
assert.equal(f.o.keepalive, true);
assert.equal(f.o.credentials, 'omit');
assert.equal(f.o.method, 'POST');
assert.equal(f.o.headers['Content-Type'], 'text/plain');
}
});
test('w.js: T19 Platz unten solange Banner offen; Knopf mindestens 24px', () => {
const h = harness({ bannerHeight: 90, bodyPad: '5px' });
assert.equal(h.body.style.paddingBottom, '90px');
btn(hostEl(h).shadow, 'Ablehnen').click();
assert.equal(h.body.style.paddingBottom, '5px');
const g = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } });
const css = findAll(g.body, (n) => n.tagName === 'STYLE').map((n) => n.textContent).join('');
assert.match(css, /min-width:24px/);
assert.match(css, /min-height:32px/);
assert.match(css, /padding:8px 12px/);
assert.match(css, /font-size:13px/);
});
test('w.js: [data-webklar-consent] oeffnet Banner, kein fester Link', () => { test('w.js: [data-webklar-consent] oeffnet Banner, kein fester Link', () => {
const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 }, withConsentLink: true }); const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 }, withConsentLink: true });
assert.equal(findAll(h.body, (x) => x.textContent === 'Cookie-Einstellungen').length, 0); assert.equal(findAll(h.body, (x) => x.textContent === SETTINGS_LABEL).length, 0);
assert.equal(hostEl(h), undefined); assert.equal(hostEl(h), undefined);
h.link.click(); h.link.click();
assert.ok(hostEl(h)); assert.ok(hostEl(h));
@@ -343,14 +469,14 @@ test('w.js: Heatmap-Modus sendet nichts, entfernt Parameter, laedt h.js', () =>
}); });
test('w.js: SPA-Navigation sendet pv nur bei Pfadwechsel', () => { test('w.js: SPA-Navigation sendet pv nur bei Pfadwechsel', () => {
const h = harness({ gpc: true }); const h = harness();
h.win.location.pathname = '/start'; h.nav('/start');
h.history.pushState(null, '', '/start?x=1'); h.timers.splice(0).forEach((f) => f()); assert.equal(h.sent.filter((s) => s.d.t === 'pv').length, 1);
assert.equal(h.sent.length, 1);
h.win.location.pathname = '/neu'; h.win.location.pathname = '/neu';
h.history.pushState(null, '', '/neu'); h.history.pushState(null, '', '/neu'); h.history.pushState(null, '', '/neu'); h.history.pushState(null, '', '/neu');
h.timers.splice(0).forEach((f) => f()); h.timers.splice(0).forEach((f) => f());
assert.deepEqual(h.sent.map((s) => s.d.path), ['/start', '/neu']); assert.deepEqual(h.sent.filter((s) => s.d.t === 'pv').map((s) => s.d.path), ['/start', '/neu']);
assert.deepEqual(h.sent.at(-1).d, { t: 'pv', site: SITE, path: '/neu' });
}); });
test('w.js: wirft nie, auch wenn localStorage/sendBeacon kaputt', () => { test('w.js: wirft nie, auch wenn localStorage/sendBeacon kaputt', () => {
@@ -361,36 +487,33 @@ test('w.js: wirft nie, auch wenn localStorage/sendBeacon kaputt', () => {
}); });
}); });
test('w.js: DNT yes / msDoNotTrack => nur pv', () => {
for (const o of [{ dnt: 'yes' }]) { const h = harness(o); assert.deepEqual(h.types(), ['pv']); }
});
test('w.js: doppelt eingebunden => nur ein pv', () => { test('w.js: doppelt eingebunden => nur ein pv', () => {
const h = harness({ gpc: true }); const h = harness();
h.rerun(); h.rerun();
assert.deepEqual(h.types(), ['pv']); assert.deepEqual(h.types(), ['pv', 'bn:shown']);
}); });
test('w.js: Widerruf in anderem Tab (storage-Ereignis) stoppt Tracking sofort', () => { test('w.js: Widerruf in anderem Tab (storage-Ereignis) stoppt Tracking sofort', () => {
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } }); const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
h.ck(target()); h.ck(target());
assert.equal(h.sent.at(-1).d.t, 'ck'); assert.equal(h.sent.at(-1).d.t, 'ck');
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 }); h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
h.storage(); h.storage();
const n = h.sent.length; const n = h.sent.length;
h.ck(target()); h.leave(); h.ck(target()); h.leave();
assert.equal(h.sent.length, n); assert.equal(h.sent.length, n);
assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation
// Annehmen in anderem Tab startet Tracking // Annehmen in anderem Tab startet Tracking
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'accept', t: 1_700_000_000_000 }); h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 });
h.storage(); h.storage();
h.ck(target()); h.ck(target());
assert.equal(h.sent.length, n + 1); assert.equal(h.sent.length, n + 1); // nur ck, kein cx (pv trug ref/vp schon)
assert.ok(!h.sent.some((x) => x.d.t === 'cx'));
}); });
test('w.js: Einwilligung wird vor jedem Senden neu geprueft (ohne storage-Ereignis)', () => { test('w.js: Einwilligung wird vor jedem Senden neu geprueft (ohne storage-Ereignis)', () => {
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } }); const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 }); h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
const n = h.sent.length; const n = h.sent.length;
h.ck(target()); h.leave(); h.ck(target()); h.leave();
assert.equal(h.sent.length, n); assert.equal(h.sent.length, n);
@@ -398,13 +521,13 @@ test('w.js: Einwilligung wird vor jedem Senden neu geprueft (ohne storage-Ereign
}); });
test('w.js: SPA-pv bei akzeptierter Einwilligung; nach Widerruf kein en', () => { test('w.js: SPA-pv bei akzeptierter Einwilligung; nach Widerruf kein en', () => {
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } }); const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
h.win.location.pathname = '/zwei'; h.win.location.pathname = '/zwei';
h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f()); h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f());
assert.deepEqual(h.sent.map((s) => s.d.path), ['/start', '/zwei']); assert.deepEqual(h.sent.map((s) => s.d.path), ['/start', '/zwei']);
h.ck(target()); h.ck(target());
assert.equal(h.sent.at(-1).d.path, '/zwei'); assert.equal(h.sent.at(-1).d.path, '/zwei');
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 }); h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
h.storage(); h.storage();
const n = h.sent.length; const n = h.sent.length;
h.leave(); h.leave();
@@ -412,7 +535,7 @@ test('w.js: SPA-pv bei akzeptierter Einwilligung; nach Widerruf kein en', () =>
}); });
test('w.js: bfcache-Rueckkehr sendet neues pv und erlaubt neues en', () => { test('w.js: bfcache-Rueckkehr sendet neues pv und erlaubt neues en', () => {
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } }); const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
h.leave(); h.leave();
assert.equal(h.sent.filter((s) => s.d.t === 'en').length, 1); assert.equal(h.sent.filter((s) => s.d.t === 'en').length, 1);
for (const f of h.winL.pageshow || []) f({ persisted: false }); for (const f of h.winL.pageshow || []) f({ persisted: false });
@@ -424,7 +547,7 @@ test('w.js: bfcache-Rueckkehr sendet neues pv und erlaubt neues en', () => {
}); });
test('w.js: [data-webklar-consent] spaeter hinzugefuegt funktioniert per Delegation', () => { test('w.js: [data-webklar-consent] spaeter hinzugefuegt funktioniert per Delegation', () => {
const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 } }); const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } });
// kein Link beim Laden => fester Link; spaeteres Element oeffnet ebenfalls // kein Link beim Laden => fester Link; spaeteres Element oeffnet ebenfalls
const late = makeEl('a'); late.setAttribute('data-webklar-consent', ''); const late = makeEl('a'); late.setAttribute('data-webklar-consent', '');
for (const f of [...(h.docL.click || [])]) f({ type: 'click', target: late, preventDefault() {} }); for (const f of [...(h.docL.click || [])]) f({ type: 'click', target: late, preventDefault() {} });
@@ -433,7 +556,7 @@ test('w.js: [data-webklar-consent] spaeter hinzugefuegt funktioniert per Delegat
}); });
// ---------- Verweildauer bei internem Seitenwechsel ---------- // ---------- Verweildauer bei internem Seitenwechsel ----------
const ACC = { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 }; const ACC = { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 };
const lnk = (href, attrs = {}) => { const a = makeEl('a'); a.setAttribute('href', href); for (const k in attrs) a.setAttribute(k, attrs[k]); a.parentNode = makeEl('body'); return a; }; const lnk = (href, attrs = {}) => { const a = makeEl('a'); a.setAttribute('href', href); for (const k in attrs) a.setAttribute(k, attrs[k]); a.parentNode = makeEl('body'); return a; };
const enCount = (h) => h.sent.filter((s) => s.d.t === 'en').length; const enCount = (h) => h.sent.filter((s) => s.d.t === 'en').length;
@@ -473,7 +596,7 @@ test('w.js: ohne Einwilligung oder nach Widerruf kein fruehes en', () => {
h.ck(lnk('/kontakt')); h.ck(lnk('/kontakt'));
assert.equal(enCount(h), 0); assert.equal(enCount(h), 0);
const g = harness({ stored: ACC }); const g = harness({ stored: ACC });
g.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 }); g.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
g.ck(lnk('/kontakt')); g.ck(lnk('/kontakt'));
assert.equal(enCount(g), 0); assert.equal(enCount(g), 0);
}); });