// @requirements REQ-PRODUCT-SETUP REQ-PRODUCT-CONFIG REQ-PRODUCT-SCOPE-GUARDS REQ-PRODUCT-SERVICE-CATALOG REQ-PRODUCT-A11Y import {test} from 'node:test';import assert from 'node:assert/strict';import {JSDOM} from 'jsdom'; import {normalizeDraftPurpose,createPurposeDraftPlan} from 'notabenda/setup/purposes';import {mountPurposeEditor} from 'notabenda/setup/purposes/ui'; import {mountSetup} from 'notabenda/setup/ui';import {serviceCatalogSeed} from 'notabenda/catalog/seed';import {validateSiteConfig,renderLegalDocument} from 'notabenda'; import {reviewSetupPurposes} from '../dist/setup-purpose-cli.js';import {createStarterConfig} from '../dist/starter.js';import {fixture} from './helpers.mjs';import {consentProfiles} from './consent-profiles.mjs'; const purpose=()=>({id:'support',name:'Respond to support enquiries',description:'Reply to messages sent through the contact form.',legalBasis:'{{purposes.support.legalBasis}}',dataCategories:['Email address','Message content'],retention:'{{purposes.support.retention}}',collection:{source:'direct',description:'Contact form',required:false,consequences:'{{purposes.support.collection.consequences}}'}}); function browser(){const dom=new JSDOM('
',{url:'https://operator.example.test/setup'});dom.window.URL.createObjectURL=()=> 'blob:purpose-test';dom.window.URL.revokeObjectURL=()=>{};return {dom,container:dom.window.document.getElementById('root')};} const button=(f,name)=>[...f.container.querySelectorAll('button')].find(b=>b.textContent===name||b.getAttribute('aria-label')===name); const field=(container,label)=>{const l=[...container.querySelectorAll('label')].find(l=>l.textContent===label);return l.htmlFor?container.ownerDocument.getElementById(l.htmlFor):l.querySelector('input,textarea,select');}; function fillPurpose(f){button(f,'Add a processing purpose').click();const panel=f.container.querySelector('.nb-setup-purpose-proposal');for(const [label,value] of [['Stable purpose ID','support'],['Purpose name','Respond to support enquiries'],['What you use the data for','Reply to messages sent through the contact form.'],['Data categories (one per line)','Email address\nMessage content'],['Where the data comes from','direct'],['Is providing this information required?','false'],['When and where you collect it','Contact form']])field(panel,label).value=value;return panel;} test('purpose inputs are closed, bounded, detached plain public facts and never execute accessors',()=>{ const input=purpose(),normalized=normalizeDraftPurpose(input);assert.deepEqual(normalized,input);normalized.dataCategories.push('Changed');assert.equal(input.dataCategories.length,2); for(const mutate of [p=>p.extra='misspelled fact',p=>p.collection.required='false',p=>p.collection.source=['direct'],p=>p.dataCategories=[],p=>p.dataCategories=Array(65).fill('x'),p=>p.description='x'.repeat(4001),p=>p.name='\u001b[31m',p=>p.collection.extra=true]){const input=purpose();mutate(input);assert.throws(()=>normalizeDraftPurpose(input));} let invoked=0;const inputWithGetter=purpose();Object.defineProperty(inputWithGetter,'description',{enumerable:true,get(){invoked++;return 'Should not run';}});assert.throws(()=>normalizeDraftPurpose(inputWithGetter));assert.equal(invoked,0); }); test('purpose drafts preserve existing restrictions and inventory, reset reviews and keep unknown facts as production blockers',async()=>{ const config=await fixture({reviewed:true}),original=structuredClone(config),added=createPurposeDraftPlan(config,{action:'add',purpose:purpose()});assert.deepEqual(config,original);assert.equal(added.config.private,undefined);assert.deepEqual(added.config.services,config.services);assert.deepEqual(added.config.storage,config.storage);assert.deepEqual(added.config.scopeGuards,config.scopeGuards);assert.equal(added.config.consent,config.consent);assert.ok(Object.values(added.config.documents).every(d=>d.review.status==='draft')); const diagnostics=validateSiteConfig(added.config,{mode:'production'}).diagnostics;assert.ok(diagnostics.some(d=>d.path==='purposes[2].legalBasis'&&d.code==='unresolved-placeholder'));assert.ok(diagnostics.some(d=>d.path==='purposes[2].retention'&&d.code==='unresolved-placeholder')); const next={...purpose(),name:'Support requests'},updated=createPurposeDraftPlan(added.config,{action:'update',previous:purpose(),purpose:next});assert.match(renderLegalDocument(updated.config,'privacy',{mode:'preview'}),/Support requests/);assert.equal(added.config.purposes.at(-1).name,purpose().name);assert.throws(()=>createPurposeDraftPlan(added.config,{action:'add',purpose:purpose()}),/already declared/); const removed=createPurposeDraftPlan(updated.config,{action:'remove',previous:next});assert.deepEqual(removed.config.purposes,config.purposes);assert.deepEqual(removed.config.services,config.services); }); test('stale facts, renames and referenced purpose removals reject without unlinking or relaxing inventory',async()=>{ const config=await fixture(),before=structuredClone(config),linked=config.purposes.find(p=>p.id==='display-preferences');assert.throws(()=>createPurposeDraftPlan(config,{action:'remove',previous:linked}),/still referenced/);assert.deepEqual(config,before); const previous=config.purposes[0],changed={...previous,name:'A changed purpose'};assert.throws(()=>createPurposeDraftPlan(config,{action:'remove',previous:changed}),/facts changed/);assert.throws(()=>createPurposeDraftPlan(config,{action:'update',previous,purpose:{...previous,id:'different'}}),/stable purpose ID/); config.scopeGuards={version:1,revision:'guard-one',site:{children:'absent',sensitiveData:'absent',highRiskProfiling:'absent',regulatedSector:'absent'},purposes:[{id:previous.id,facts:{sensitiveData:'present'}}]};assert.throws(()=>createPurposeDraftPlan(config,{action:'remove',previous}),/scope restriction/);const edited=createPurposeDraftPlan(config,{action:'update',previous,purpose:changed});assert.deepEqual(edited.config.scopeGuards,config.scopeGuards); const repeated=await fixture();repeated.purposes[0].dataCategories.push(repeated.purposes[0].dataCategories[0]);assert.equal(validateSiteConfig(repeated,{mode:'preview'}).valid,true);const correction={...repeated.purposes[0],dataCategories:[...new Set(repeated.purposes[0].dataCategories)]};assert.deepEqual(createPurposeDraftPlan(repeated,{action:'update',previous:repeated.purposes[0],purpose:correction}).config.purposes[0],correction); }); // @requirements REQ-PRODUCT-CONSENT-RUNTIME REQ-EU-CONSENT test('edited purpose meaning cannot inherit a persisted visitor grant in either runtime profile',async()=>{ const config=await fixture();config.services=[{id:'support-service',name:'Support',provider:'Local fixture',purposeIds:['enquiries'],location:'Test region'}];config.storage=[];config.consent={purposes:[{id:'enquiries',scope:'one'}],services:[{id:'support-service',scope:'one'}],grantLifetimeDays:1,refusalLifetimeDays:30,dismissalMinutes:5}; for(const [name,create] of consentProfiles){const values=new Map(),storage={getItem:key=>values.get(key)??null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},environment={storage,now:()=>Date.parse('2026-10-19T12:00:00Z')};let c=create(config,environment);c.acceptAll();assert.equal(c.isAllowed('enquiries'),true,name);c.destroy();const previous=config.purposes[0],edited=createPurposeDraftPlan(config,{action:'update',previous,purpose:{...previous,description:'A different material use of the data.'}});assert.deepEqual(edited.config.consent,config.consent);c=create(edited.config,environment);assert.equal(c.isAllowed('enquiries'),false,name);c.destroy();} }); test('purpose editor supports explicit create/edit/remove, unresolved placeholders, validation, cancellation and status focus',()=>{ const f=browser();let config=createStarterConfig(),calls=0;const editor=mountPurposeEditor(f.container,{getConfig:()=>config,onChange:result=>{calls++;config=result.config;}});f.container.querySelector('details').open=true;button(f,'Add a processing purpose').click();button(f,'Add purpose to draft').click();assert.equal(calls,0);assert.ok(f.container.querySelector('[aria-invalid=true]'));button(f,'Cancel purpose edit').click();assert.equal(calls,0); const panel=fillPurpose(f);const input=field(panel,'Purpose name'),enter=new f.dom.window.KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true});input.dispatchEvent(enter);assert.equal(enter.defaultPrevented,true);button(f,'Add purpose to draft').click();assert.equal(calls,1);assert.deepEqual(config.purposes,[purpose()]);assert.equal(f.dom.window.document.activeElement,f.container.querySelector('[role=status]')); button(f,'Edit purpose '+purpose().name).click();assert.equal(field(f.container.querySelector('.nb-setup-purpose-proposal'),'Stable purpose ID').readOnly,true);field(f.container.querySelector('.nb-setup-purpose-proposal'),'Purpose name').value='Support requests';button(f,'Save purpose changes').click();assert.equal(config.purposes[0].name,'Support requests');button(f,'Remove purpose Support requests').click();assert.equal(config.purposes.length,1);button(f,'Confirm purpose removal').click();assert.equal(config.purposes.length,0);editor.destroy();assert.equal(f.container.childElementCount,0);f.dom.window.close(); }); test('purpose editor rejects stale site or purpose facts, renders text safely and distinguishes a host update failure',()=>{ const f=browser();let config=createPurposeDraftPlan(createStarterConfig(),{action:'add',purpose:purpose()}).config,calls=0;const editor=mountPurposeEditor(f.container,{getConfig:()=>config,onChange:()=>calls++});f.container.querySelector('details').open=true;button(f,'Edit purpose '+purpose().name).click();field(f.container.querySelector('.nb-setup-purpose-proposal'),'Purpose name').value='