// @requirements REQ-GOOGLE-MODE REQ-PRODUCT-CONFIG REQ-PRODUCT-CONSENT-RUNTIME REQ-PRODUCT-PRIVACY-SIGNALS import {test} from 'node:test'; import assert from 'node:assert/strict'; import {JSDOM,ResourceLoader,VirtualConsole} from 'jsdom'; import {createConsent,toPublicConfig,validateSiteConfig} from 'notabenda'; import {registerGoogleBasic,createGoogleBasicBrowserEnvironment} from 'notabenda/google'; import {compileLegalDocuments,createConsentNotice} from 'notabenda/content'; import {recordLegalRelease,registerConsentNotice,setActiveConsentNotice,getConsentHead,recordConsentChoice} from 'notabenda-cf'; import {createSyntheticStorage} from '../../notabenda-cf/examples/synthetic.mjs'; import {fixture} from './helpers.mjs'; const denied={analytics_storage:'denied',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'}; const deferred=()=>{let resolve,reject;return {promise:new Promise((a,b)=>{resolve=a;reject=b;}),get resolve(){return resolve;},get reject(){return reject;}};}; async function setup(){const config=await fixture({reviewed:true});config.services=[{id:'google',name:'Optional measurement',provider:'Google',location:'Declared Google processing',purposeIds:['enquiries'],resourceOrigins:['https://www.googletagmanager.com'],processingActivities:[],googleBasic:{version:1,tagIds:['G-TEST1234'],signals:{analytics_storage:['enquiries'],ad_storage:[],ad_user_data:[],ad_personalization:[]}}}];config.storage=[];config.consent={purposes:[{id:'enquiries',scope:'measurement-1'}],services:[{id:'google',scope:'tag-1'}],grantLifetimeDays:30,refusalLifetimeDays:180,dismissalMinutes:30};const choices=new Map(),env={storage:{getItem:k=>choices.get(k)??null,setItem:(k,v)=>choices.set(k,v),removeItem:k=>choices.delete(k)},schedule:()=>()=>{}};return {config,env,choices};} function port(options={}){const commands=[],loads=[],gate=options.gate??{promise:Promise.resolve()},record={commands,loads,stops:0,enables:0};record.environment={connect(ids){record.ids=ids;return {command(...args){commands.push(args);options.command?.(args);},enable(){record.enables++;},stop(){record.stops++;},load(url,signal){loads.push({url,signal});return gate.promise;}};}};return record;} test('Google configuration binds exact product signal mapping to public projection and material consent scope',async()=>{ const f=await setup();assert.equal(validateSiteConfig(f.config).valid,true);const projected=toPublicConfig(f.config);assert.deepEqual(projected.services[0].googleBasic,f.config.services[0].googleBasic);projected.services[0].googleBasic.signals.analytics_storage.length=0;assert.equal(f.config.services[0].googleBasic.signals.analytics_storage.length,1); const c=createConsent(f.config,f.env),first=port(),a=registerGoogleBasic({consent:c,serviceId:'google'},first.environment);c.acceptAll();await c.settled();assert.equal(first.loads.length,1);a.destroy();await c.settled();c.destroy(); const changed=structuredClone(f.config);changed.services[0].googleBasic.tagIds=['G-OTHER1234'];const next=createConsent(changed,f.env),second=port(),b=registerGoogleBasic({consent:next,serviceId:'google'},second.environment);await next.settled();assert.equal(next.getSnapshot().purposes.enquiries,'unknown');assert.equal(second.loads.length,0);b.destroy();next.destroy(); for(const mutate of [c=>delete c.services[0].googleBasic.signals.ad_user_data,c=>c.services[0].googleBasic.tagIds=['GTM-1234'],c=>{c.services[0].googleBasic.signals.analytics_storage=[];c.services[0].googleBasic.signals.ad_storage=['enquiries'];},c=>{c.services[0].googleBasic.tagIds=['AW-123456'];c.services[0].googleBasic.signals.ad_storage=['enquiries'];},c=>delete c.services[0].processingActivities,c=>c.services[0].resourceOrigins=['https://other.example.test'],c=>c.consent.services=[]]){const invalid=structuredClone(f.config);mutate(invalid);const result=validateSiteConfig(invalid);assert.equal(result.valid,false);assert.ok(result.diagnostics.some(d=>d.requirementIds.includes('REQ-GOOGLE-MODE')));} }); test('initial unknown and rejection stay silent, then defaults precede authorized initialization with all four signals',async()=>{ const f=await setup(),c=createConsent(f.config,f.env),p=port(),a=registerGoogleBasic({consent:c,serviceId:'google'},p.environment);assert.deepEqual(p.commands,[['consent','default',denied]]);assert.equal(a.event('page_view',{page_title:'Never buffered'}),false);await c.settled();assert.equal(p.loads.length,0);c.rejectAll();await c.settled();assert.equal(p.loads.length,0);assert.equal(a.getSnapshot().state,'blocked');c.acceptAll();await c.settled();assert.equal(a.getSnapshot().state,'active');assert.deepEqual(p.commands[1],['consent','update',{...denied,analytics_storage:'granted'}]);assert.equal(p.commands[2][0],'js');assert.equal(p.commands[3][0],'config');assert.equal(p.commands[3][2].send_page_view,false);assert.equal(p.commands[3][2].allow_google_signals,false);assert.equal(p.loads[0].url,'https://www.googletagmanager.com/gtag/js?id=G-TEST1234');assert.equal(a.event('page_view',{page_title:'Reviewed page'}),true);assert.deepEqual(p.commands.at(-1),['event','page_view',{page_title:'Reviewed page',send_to:'G-TEST1234'}]);a.destroy();await c.settled();c.destroy(); }); test('withdrawal closes events synchronously, sends denial after stopping and permanently requires reload',async()=>{ const f=await setup(),c=createConsent(f.config,f.env),p=port(),a=registerGoogleBasic({consent:c,serviceId:'google'},p.environment);c.acceptAll();await c.settled();c.rejectAll();assert.equal(a.getSnapshot().state,'reload-required');assert.ok(p.stops>0);assert.equal(a.event('after_withdrawal'),false);assert.deepEqual(p.commands.at(-1),['consent','update',denied]);await c.settled();assert.equal(c.getSnapshot().services.google,'reload-required');c.acceptAll();await c.settled();assert.equal(p.loads.length,1);assert.equal(a.event('after_regrant'),false);a.destroy();c.destroy(); }); test('late loaders, load errors and bounded timeouts cannot restore an aborted Google generation',async()=>{ for(const mode of ['late','error','timeout']){const f=await setup(),c=createConsent(f.config,f.env),gate=deferred(),p=port({gate}),a=registerGoogleBasic({consent:c,serviceId:'google',timeoutMs:100},p.environment);c.acceptAll();await Promise.resolve();await Promise.resolve();assert.equal(p.loads.length,1);if(mode==='late'){c.rejectAll();assert.equal(a.getSnapshot().state,'reload-required');gate.resolve();}else if(mode==='error')gate.reject(Error('Offline'));await c.settled();assert.equal(a.getSnapshot().state,'reload-required');assert.equal(c.getSnapshot().services.google,'reload-required');assert.equal(a.event('late_event'),false);a.destroy();c.destroy();} }); test('reentrant withdrawal during initialization does not initialize or insert a tag afterward',async()=>{ const f=await setup(),c=createConsent(f.config,f.env),p=port({command(args){if(args[0]==='consent'&&args[1]==='update'&&args[2].analytics_storage==='granted')c.rejectAll();}}),a=registerGoogleBasic({consent:c,serviceId:'google'},p.environment);c.acceptAll();await c.settled();assert.equal(p.loads.length,0);assert.equal(p.commands.some(args=>['config','js'].includes(args[0])),false);assert.equal(a.event('not_sent'),false);a.destroy();c.destroy(); }); test('Google events reject routing/identity overrides and getters rather than invoking or buffering them',async()=>{ const f=await setup(),c=createConsent(f.config,f.env),p=port(),a=registerGoogleBasic({consent:c,serviceId:'google'},p.environment);let called=0;const fields={};Object.defineProperty(fields,'value',{enumerable:true,get(){called++;return 1;}});assert.throws(()=>a.event('event',fields));assert.equal(called,0);const hidden={};for(let i=0;i<33;i++)Object.defineProperty(hidden,'field_'+i,{value:i});assert.throws(()=>a.event('event',hidden),/32 string-keyed/);assert.throws(()=>a.event('event',{[Symbol('hidden')]:1}),/string-keyed/);for(const key of ['send_to','transport_url','server_container_url','event_callback','user_id'])assert.throws(()=>a.event('event',{[key]:'forbidden'}));assert.throws(()=>a.event('event',{value:NaN}));assert.equal(p.loads.length,0);a.destroy();c.destroy(); }); test('effective service gating includes pending receipts and independent GPC restrictions',async()=>{ const f=await setup(),release=compileLegalDocuments(f.config),notice=createConsentNotice(release.artifact,{scopeId:'google-choices'}),storage=createSyntheticStorage([]),context={siteId:f.config.site.id,scopeId:notice.scopeId,streamId:'opaque-browser'};await recordLegalRelease(storage,release.artifact);await registerConsentNotice(storage,notice,context);await setActiveConsentNotice(storage,notice.noticeId,context);const head=await getConsentHead(storage,context),gate=deferred(); const c=createConsent(f.config,f.env,{evidence:{notice,streamRef:head.streamRef,read:()=>getConsentHead(storage,context),write:async body=>{await gate.promise;return recordConsentChoice(storage,body,context);}}}),p=port(),a=registerGoogleBasic({consent:c,serviceId:'google'},p.environment);await c.settled();c.acceptAll();await Promise.resolve();assert.equal(p.loads.length,0);gate.resolve();await c.settled();assert.equal(p.loads.length,1);a.destroy();await c.settled();c.destroy(); const blocked=structuredClone(f.config);blocked.services[0].processingActivities=['sharing'];blocked.privacySignals={globalPrivacyControl:{appliesTo:['sharing'],explanation:'Do not share measurement under the configured signal policy.'}};const d=createConsent(blocked,{...f.env,globalPrivacyControl:()=>true}),q=port(),b=registerGoogleBasic({consent:d,serviceId:'google'},q.environment);d.acceptAll();await d.settled();assert.equal(q.loads.length,0);b.destroy();d.destroy(); }); test('the real DOM loader attempts no Google requests before grant or initial rejection, and disables before withdrawal update',async()=>{ const f=await setup(),requests=[];class Loader extends ResourceLoader{fetch(url){requests.push(url);return Promise.resolve(Buffer.from('window.__googleCommands=Array.from(window.dataLayer, x=>Array.from(x));const original=window.dataLayer.push;window.dataLayer.push=function(x){window.__googleCommands.push({args:Array.from(x),disabled:window["ga-disable-G-TEST1234"]});return original.call(this,x);}'));}} const dom=new JSDOM('',{url:'https://example.test/',runScripts:'dangerously',resources:new Loader(),virtualConsole:new VirtualConsole()}),c=createConsent(f.config,f.env),environment=createGoogleBasicBrowserEnvironment({document:dom.window.document}),a=registerGoogleBasic({consent:c,serviceId:'google'},environment);assert.equal(dom.window['ga-disable-G-TEST1234'],true);dom.window.gtag('event','must_not_buffer');assert.equal(dom.window.dataLayer.length,1);await c.settled();assert.deepEqual(requests,[]);c.rejectAll();await c.settled();assert.deepEqual(requests,[]);c.acceptAll();await c.settled();assert.equal(requests.length,1);assert.match(requests[0],/^https:\/\/www.googletagmanager.com\/gtag\/js\?id=G-TEST1234$/);assert.deepEqual(Array.from(dom.window.__googleCommands[0]),['consent','default',denied]);assert.equal(a.event('page_view'),true);c.rejectAll();assert.equal(dom.window['ga-disable-G-TEST1234'],true);assert.equal(dom.window.document.querySelectorAll('script[src]').length,0);assert.equal(dom.window.__googleCommands.at(-1).disabled,true);assert.equal(a.event('blocked'),false);await c.settled();a.destroy();c.destroy();assert.throws(()=>registerGoogleBasic({consent:createConsent(f.config,f.env),serviceId:'google'},environment),/one owned Google service/);dom.window.close(); }); test('preexisting Google globals, hints or scripts fail closed without attempting a load',async()=>{ for(const preexisting of ['gtag','dataLayer','disabled','hint','script']){const f=await setup(),dom=new JSDOM('',{url:'https://example.test/'});if(preexisting==='gtag')dom.window.gtag=()=>{};if(preexisting==='disabled')dom.window['ga-disable-G-TEST1234']=true;if(preexisting==='dataLayer')dom.window.dataLayer=[];if(preexisting==='hint')dom.window.document.head.innerHTML='';if(preexisting==='script')dom.window.document.head.innerHTML='';const c=createConsent(f.config,f.env);assert.throws(()=>registerGoogleBasic({consent:c,serviceId:'google'},createGoogleBasicBrowserEnvironment({document:dom.window.document})),/before any Google tag/);assert.equal(dom.window.document.querySelectorAll('script[src]').length,preexisting==='script'?1:0);c.destroy();dom.window.close();} }); test('pending DOM load cancellation preserves the final denied command after repeated abort cleanup',async()=>{ const f=await setup(),gate=deferred();gate.promise.abort=()=>{};class Loader extends ResourceLoader{fetch(){return gate.promise;}}const dom=new JSDOM('',{url:'https://example.test/',runScripts:'dangerously',resources:new Loader(),virtualConsole:new VirtualConsole()}),c=createConsent(f.config,f.env),a=registerGoogleBasic({consent:c,serviceId:'google'},createGoogleBasicBrowserEnvironment({document:dom.window.document}));c.acceptAll();await Promise.resolve();await Promise.resolve();assert.equal(a.getSnapshot().state,'loading');c.rejectAll();await c.settled();assert.equal(dom.window['ga-disable-G-TEST1234'],true);assert.deepEqual(Array.from(dom.window.dataLayer.at(-1)),['consent','update',denied]);gate.resolve(Buffer.from(''));a.destroy();c.destroy();dom.window.close(); }); test('eligibility is resampled after loading observers and after asynchronous loader completion',async()=>{ for(const phase of ['observer','completion']){const f=await setup();f.config.services[0].processingActivities=['sharing'];f.config.privacySignals={globalPrivacyControl:{appliesTo:['sharing'],explanation:'Keep sharing off under this signal policy.'}};let gpc=false;const c=createConsent(f.config,{...f.env,globalPrivacyControl:()=>gpc}),gate=deferred(),p=port({gate}),a=registerGoogleBasic({consent:c,serviceId:'google'},p.environment);if(phase==='observer')a.subscribe(state=>{if(state.state==='loading')gpc=true;});c.acceptAll();await Promise.resolve();await Promise.resolve();if(phase==='completion'){assert.equal(p.loads.length,1);gpc=true;gate.resolve();}await c.settled();assert.equal(p.loads.length,phase==='observer'?0:1);assert.notEqual(a.getSnapshot().state,'active');assert.equal(a.event('must_not_send'),false);a.destroy();c.destroy();} });