// @requirements REQ-PRODUCT-SERVICE-CATALOG REQ-PRODUCT-SETUP REQ-PRODUCT-A11Y import {test} from 'node:test';import assert from 'node:assert/strict'; import {JSDOM} from 'jsdom';import {mkdtemp,writeFile,readFile,rm} from 'node:fs/promises';import {tmpdir} from 'node:os';import {join} from 'node:path';import {spawnSync} from 'node:child_process'; import {createCatalogSelectionPlan,normalizeCatalogServiceSelection,normalizeSetupCatalogSelections,readSetupCatalogSelections} from 'notabenda/catalog/selection'; import {mountCatalogPicker} from 'notabenda/catalog/ui';import {serviceCatalogSeed} from 'notabenda/catalog/seed';import {createSetupPlan,readSetupAnswers} from 'notabenda/setup';import {mountSetup} from 'notabenda/setup/ui';import {compileLegalDocuments} from 'notabenda/content';import {fixture} from './helpers.mjs'; const answers={schemaVersion:1,markets:[],answers:{}}; const choice=()=>({productId:'stripe-payments',serviceId:'payments',purposeIds:['enquiries'],provider:'Explicit operator-confirmed entity',location:'Operator-selected region',privacyUrl:'https://stripe.com/privacy',justification:'PRIVATE_REASON_SENTINEL',confirmed:true}); const selected=async()=>createCatalogSelectionPlan(await fixture({reviewed:true}),serviceCatalogSeed,choice()); function browser(){const dom=new JSDOM('
',{url:'https://operator.example.test/setup'});dom.window.URL.createObjectURL=()=> 'blob:test';dom.window.URL.revokeObjectURL=()=>{};return {dom,container:dom.window.document.getElementById('root')};} const button=(container,text)=>[...container.querySelectorAll('button')].find(b=>b.textContent===text); function propose(f){const query=f.container.querySelector('input[type=search]');query.value='stripe';button(f.container,'Search catalog').click();button(f.container,'Review Stripe payment libraries').click();const proposal=f.container.querySelector('.nb-catalog-proposal');const input=label=>[...proposal.querySelectorAll('label')].find(l=>l.firstChild.textContent===label)?.querySelector('input,textarea');input('Actual provider entity').value='Explicit entity';input('Actual processing location').value='Actual region';input('Supporting facts / justification (private)').value='PRIVATE_REASON_SENTINEL';proposal.querySelector('input[value=enquiries]').checked=true;return proposal;} test('explicit selection preserves restrictions and creates detached ordinary inventory plus minimized private pins',async()=>{ const config=await fixture({reviewed:true});config.private={note:'DO_NOT_EXPORT_PRIVATE_CONFIG'};const original=structuredClone(config),result=createCatalogSelectionPlan(config,serviceCatalogSeed,choice());assert.deepEqual(config,original);assert.equal(result.config.private,undefined);assert.deepEqual(result.config.storage,config.storage);assert.equal(result.config.consent,config.consent);assert.ok(Object.values(result.config.documents).every(d=>d.review.status==='draft')); const record=result.selection;assert.equal(record.reference.products.length,1);assert.equal(record.reference.providers.length,1);assert.deepEqual(record.reference.products[0].identifiers,[]);assert.deepEqual(record.reference.products[0].storageHints,[]);assert.equal(record.reference.sources[0].checkedOn,null);assert.equal(record.reference.sources[0].access,'reference-only');assert.equal(record.reference.products[0].review,'unreviewed');assert.equal(record.review,'operator-confirmed');assert.equal(record.justification,'PRIVATE_REASON_SENTINEL'); assert.doesNotMatch(JSON.stringify(compileLegalDocuments(result.config,{mode:'preview'})),/PRIVATE_REASON_SENTINEL|DO_NOT_EXPORT_PRIVATE_CONFIG|catalog-service-selection|catalogRevision/);result.config.services.at(-1).provider='Changed';assert.equal(record.service.provider,'Explicit operator-confirmed entity'); }); test('missing confirmation, invalid actual use, collisions and executable/coerced pins fail without config mutation',async()=>{ const config=await fixture({reviewed:true}),original=structuredClone(config); for(const values of [{confirmed:false},{justification:''},{provider:''},{location:''},{purposeIds:['missing']},{serviceId:config.services[0].id},{privacyUrl:'javascript:bad'},{productId:'missing'}])assert.throws(()=>createCatalogSelectionPlan(config,serviceCatalogSeed,{...choice(),...values}));assert.deepEqual(config,original); const result=await selected();for(const mutate of [r=>r.review=['operator-confirmed'],r=>r.service.name='',r=>r.service.processingActivities=['sale'],r=>r.reference.products.push(r.reference.products[0]),r=>r.reference.products[0].identifiers=[{id:'extra'}],r=>r.justification='x'.repeat(4001)]){const r=structuredClone(result.selection);mutate(r);assert.throws(()=>normalizeCatalogServiceSelection(r));} let invoked=0;const hostile=structuredClone(result.selection);Object.defineProperty(hostile,'getter',{enumerable:true,get(){invoked++;return 'bad';}});assert.throws(()=>normalizeCatalogServiceSelection(hostile));assert.equal(invoked,0); }); test('private setup reports pin prior reference facts and resume only with exactly matching inventory',async()=>{ const {config,selection}=await selected(),plan=createSetupPlan(answers,{config,catalogSelections:[selection]});assert.equal(plan.report.catalogSelections[0].justification,'PRIVATE_REASON_SENTINEL');const resumed=readSetupCatalogSelections(plan.report);assert.equal(resumed[0].reference.revision,serviceCatalogSeed.revision); assert.deepEqual(createSetupPlan(readSetupAnswers(plan.report),{config:plan.config,catalogSelections:resumed}).report.catalogSelections,resumed);const changed=structuredClone(config);changed.services.at(-1).provider='Changed entity';assert.throws(()=>createSetupPlan(answers,{config:changed,catalogSelections:resumed}),/matching draft config/);assert.throws(()=>normalizeSetupCatalogSelections([selection,selection],config));assert.throws(()=>normalizeSetupCatalogSelections(Array(33).fill(selection),config)); const extended=structuredClone(config);extended.services.at(-1).resourceOrigins=['https://example.test'];assert.equal(normalizeSetupCatalogSelections([selection],extended).length,1); const nextCatalog=structuredClone(serviceCatalogSeed);nextCatalog.revision='later-reference';nextCatalog.products.find(p=>p.id==='stripe-payments').name='New reference label';assert.equal(readSetupCatalogSelections(plan.report)[0].service.name,'Stripe payment libraries');assert.deepEqual(readSetupCatalogSelections({schemaVersion:1,markets:[],answers:{}}),[]); }); test('the picker requires a confirmed actual-use review, escapes source metadata and makes only an explicit draft callback',async()=>{ const f=browser(),config=await fixture({reviewed:true});let calls=0,result;const ui=mountCatalogPicker(f.container,{catalog:serviceCatalogSeed,getConfig:()=>config,onSelect:selection=>{calls++;result=selection;}}),proposal=propose(f); button(f.container,'Add reviewed service to draft').click();assert.equal(calls,0);assert.match(proposal.querySelector('[role=alert]').textContent,/Nothing was added/);proposal.querySelectorAll('input[type=checkbox]').item(proposal.querySelectorAll('input[type=checkbox]').length-1).checked=true;button(f.container,'Add reviewed service to draft').click();assert.equal(calls,1);assert.equal(result.selection.justification,'PRIVATE_REASON_SENTINEL');assert.equal(config.services.length,1);assert.match(f.container.querySelector('[role=status]').textContent,/Added Stripe/);assert.equal(f.dom.window.document.activeElement,f.container.querySelector('[role=status]'));ui.destroy();f.dom.window.close(); }); test('a host callback failure cannot be reported as proof that no draft change occurred',async()=>{ const f=browser(),config=await fixture({reviewed:true}),ui=mountCatalogPicker(f.container,{catalog:serviceCatalogSeed,getConfig:()=>config,onSelect:()=>{throw Error('private host failure');}});const proposal=propose(f);proposal.querySelectorAll('input[type=checkbox]').item(proposal.querySelectorAll('input[type=checkbox]').length-1).checked=true;button(f.container,'Add reviewed service to draft').click();assert.match(proposal.querySelector('[role=alert]').textContent,/Could not confirm the host draft update/);assert.doesNotMatch(proposal.querySelector('[role=alert]').textContent,/Nothing was added|private host failure/);ui.destroy();f.dom.window.close(); }); test('same-ID purpose changes require renewed review when adding or resuming a catalog selection',async()=>{ const original=await fixture({reviewed:true}),{config,selection}=createCatalogSelectionPlan(original,serviceCatalogSeed,choice()); assert.deepEqual(selection.purposes,[original.purposes.find(p=>p.id==='enquiries')]); for(const change of [p=>p.name='Behavioral advertising',p=>p.description+=' Changed',p=>p.legalBasis='consent',p=>p.dataCategories.push('New category'),p=>p.retention='One day',p=>p.collection.required=!p.collection.required]){ const changed=structuredClone(config);change(changed.purposes.find(p=>p.id==='enquiries')); assert.throws(()=>normalizeSetupCatalogSelections([selection],changed),/reviewed purpose facts/); changed.services.pop();assert.throws(()=>createCatalogSelectionPlan(changed,serviceCatalogSeed,{...choice(),reviewedPurposes:selection.purposes}),/Purpose facts changed/); } const malformed=structuredClone(selection);malformed.purposes[0].extra='not a purpose fact';assert.throws(()=>normalizeCatalogServiceSelection(malformed)); const f=browser();let current=original,calls=0;const ui=mountCatalogPicker(f.container,{catalog:serviceCatalogSeed,getConfig:()=>current,onSelect:()=>calls++});const proposal=propose(f);proposal.querySelectorAll('input[type=checkbox]').item(proposal.querySelectorAll('input[type=checkbox]').length-1).checked=true; current=structuredClone(original);current.purposes.find(p=>p.id==='enquiries').name='Behavioral advertising';button(f.container,'Add reviewed service to draft').click();assert.equal(calls,0);assert.match(proposal.querySelector('[role=alert]').textContent,/reopen this service review/); const updated=propose(f);assert.match(updated.textContent,/Behavioral advertising/);updated.querySelectorAll('input[type=checkbox]').item(updated.querySelectorAll('input[type=checkbox]').length-1).checked=true;button(f.container,'Add reviewed service to draft').click();assert.equal(calls,1);ui.destroy();f.dom.window.close(); }); test('lazy catalog loading retries and ignores completion after destruction without touching host configuration',async()=>{ const f=browser(),config=await fixture();let attempts=0,finish;const ui=mountCatalogPicker(f.container,{getConfig:()=>config,onSelect:()=>assert.fail(),loadCatalog:()=>{attempts++;return attempts===1?Promise.reject(Error('private loader details')):new Promise(resolve=>{finish=resolve;});}});const details=f.container.querySelector('details');details.open=true;details.dispatchEvent(new f.dom.window.Event('toggle'));await new Promise(resolve=>setImmediate(resolve));assert.equal(attempts,1);assert.match(f.container.textContent,/could not be loaded/);assert.doesNotMatch(f.container.textContent,/private loader details/);details.open=false;details.dispatchEvent(new f.dom.window.Event('toggle'));details.open=true;details.dispatchEvent(new f.dom.window.Event('toggle'));assert.equal(attempts,2);ui.destroy();finish(serviceCatalogSeed);await new Promise(resolve=>setImmediate(resolve));assert.equal(f.container.childElementCount,0);f.dom.window.close(); }); test('admin setup records private selections, ignores proposal edits for exports and never removes unrelated same-ID facts',async()=>{ const f=browser(),config=await fixture({reviewed:true}),ui=mountSetup(f.container,{config,catalog:serviceCatalogSeed});const submit=()=>f.container.querySelector('form').dispatchEvent(new f.dom.window.Event('submit',{cancelable:true}));submit();assert.ok(ui.getPlan());const proposal=propose(f);const enter=new f.dom.window.KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true});proposal.querySelector('input').dispatchEvent(enter);assert.equal(enter.defaultPrevented,true,'proposal Enter cannot implicitly submit the enclosing setup form');assert.ok(ui.getPlan(),'search/proposal edits have not changed the draft');proposal.querySelectorAll('input[type=checkbox]').item(proposal.querySelectorAll('input[type=checkbox]').length-1).checked=true;button(f.container,'Add reviewed service to draft').click();assert.equal(ui.getPlan(),null);submit();const plan=ui.getPlan();assert.equal(plan.report.catalogSelections.length,1);assert.equal(plan.config.services.length,2);assert.equal(plan.report.catalogSelections[0].service.id,'stripe-payments');assert.doesNotMatch(JSON.stringify(compileLegalDocuments(plan.config,{mode:'preview'})),/PRIVATE_REASON_SENTINEL/); const unrelated=structuredClone(plan.config);unrelated.services.at(-1).provider='Unrelated modified entity';const file=f.container.querySelector('input[type=file]');Object.defineProperty(file,'files',{value:[{size:100,text:async()=>JSON.stringify(unrelated)}]});file.dispatchEvent(new f.dom.window.Event('change'));await new Promise(resolve=>setImmediate(resolve));button(f.container,'Remove catalog selection for Stripe payment libraries').click();submit();assert.equal(ui.getPlan().config.services.at(-1).provider,'Unrelated modified entity');ui.destroy();f.dom.window.close(); }); test('noninteractive CLI resumes pinned reviews with their matching config, omits private stdout and rejects mismatched inventory before writes',async()=>{ const cwd=await mkdtemp(join(tmpdir(),'notabenda-selection-'));try{const {config,selection}=await selected(),plan=createSetupPlan(answers,{config,catalogSelections:[selection]});await writeFile(join(cwd,'report.json'),JSON.stringify(plan.report));await writeFile(join(cwd,'config.json'),JSON.stringify(plan.config));const cli=new URL('../dist/cli.js',import.meta.url).pathname;const args=[cli,'wizard','--answers',join(cwd,'report.json'),'--config',join(cwd,'config.json'),'--out-dir',join(cwd,'out')];let r=spawnSync(process.execPath,args,{encoding:'utf8'});assert.equal(r.status,0,r.stderr);assert.doesNotMatch(r.stdout,/PRIVATE_REASON_SENTINEL/);const report=JSON.parse(await readFile(join(cwd,'out/setup.private.json'),'utf8'));assert.equal(report.catalogSelections[0].reference.revision,serviceCatalogSeed.revision); config.services=[];config.storage=[];await writeFile(join(cwd,'config.json'),JSON.stringify(config));args[args.length-1]=join(cwd,'bad');r=spawnSync(process.execPath,args,{encoding:'utf8'});assert.notEqual(r.status,0);await assert.rejects(readFile(join(cwd,'bad/notabenda.config.json'))); }finally{await rm(cwd,{recursive:true,force:true});} });