// @requirements REQ-PRODUCT-SERVICE-PROFILES
import {test} from 'node:test';import assert from 'node:assert/strict';import {JSDOM} from 'jsdom';import {spawnSync} from 'node:child_process';import {build} from 'esbuild';
import {serviceCatalogSeed} from 'notabenda/catalog/seed';import {normalizeServiceCatalog,createCatalogServiceDraft} from 'notabenda/catalog';import {readCatalogProductProfile,renderCatalogProductProfile,formatCatalogProductProfile} from 'notabenda/catalog/profile';import {mountCatalogPicker,mountCatalogSelectionReview} from 'notabenda/catalog/ui';import {createCatalogSelectionPlan,normalizeCatalogServiceSelection} from 'notabenda/catalog/selection';import {prepareCatalogSelectionReview,confirmCatalogSelectionReview} from 'notabenda/catalog/review';import {attachCatalogReview} from 'notabenda/catalog/release';import {compileLegalDocuments} from 'notabenda/content';import {recordLegalRelease,createDocumentHistory,createAdminWorker} from 'notabenda-cf';import {createSyntheticStorage} from '../../notabenda-cf/examples/synthetic.mjs';import {fixture} from './helpers.mjs';import {mountSetup} from 'notabenda/setup/ui';
const compact=()=>{const c=structuredClone(serviceCatalogSeed);c.products=c.products.filter(p=>p.id==='stripe-payments');c.providers=c.providers.filter(p=>p.id==='stripe');const ids=new Set([...c.products[0].sourceIds,...c.providers[0].sourceIds]);c.sources=c.sources.filter(s=>ids.has(s.id));return c;};
const choose={productId:'stripe-payments',serviceId:'payments',purposeIds:['enquiries'],provider:'Actual entity',location:'Actual location',justification:'Private operator reason',confirmed:true};
const button=(root,text)=>[...root.querySelectorAll('button')].find(b=>b.textContent===text);
const field=(root,text)=>[...root.querySelectorAll('label')].find(l=>l.firstChild?.textContent===text)?.querySelector('input,textarea');
test('optional profiles preserve old v1 catalogs and detached read-only notes with source status',()=>{
const c=compact();assert.equal(normalizeServiceCatalog(c).products[0].profile.schemaVersion,1);delete c.products[0].profile;assert.equal(readCatalogProductProfile(c,'stripe-payments'),undefined);assert.equal(renderCatalogProductProfile(c,'stripe-payments'),'');assert.throws(()=>readCatalogProductProfile(c,'absent'));
const view=readCatalogProductProfile(serviceCatalogSeed,'resend-email');assert.ok(view.profile.facts.every(f=>f.sourceIds.every(id=>view.sources.find(s=>s.id===id)?.access==='read')));assert.equal(view.sources.find(s=>s.id==='profile-resend-dpa').checkedOn,null);view.profile.facts[0].description='Changed';assert.notEqual(readCatalogProductProfile(serviceCatalogSeed,'resend-email').profile.facts[0].description,'Changed');
assert.deepEqual(serviceCatalogSeed.products.filter(p=>p.profile).map(p=>p.id),['cloudflare-workers','resend-email','stripe-payments','supabase-platform']);assert.ok(serviceCatalogSeed.products.every(p=>p.review==='unreviewed'));
});
test('profile schema rejects dangling/unread facts, unsafe URLs, duplicate IDs, coercion, controls and budgets',()=>{
for(const mutate of [
(p,c)=>p.facts[0].sourceIds=['absent'],(p,c)=>p.facts[0].sourceIds=[c.sources.find(s=>s.access==='reference-only').id],p=>p.facts[0].sourceIds=[],p=>p.facts.push(p.facts[0]),p=>p.links[0].kind=['privacy'],p=>p.links[0].url='https://stripe.com/privacy?tenant=private',p=>p.links[0].url='https://stripe.com/privacy#private',p=>p.links[0].url='https://stripe.com/other',p=>p.links.push({...p.links[0],id:'duplicate-privacy'}),p=>p.reviewPrompts[0].requirementIds=['javascript:bad'],p=>p.facts[0].description='bad\u001b[2J',p=>p.facts[0].description='x'.repeat(701),p=>p.automaticRegion='us',p=>p.schemaVersion=2,
(p,c)=>{c.sources.find(s=>s.id===p.facts[0].sourceIds[0]).url+='?private=tenant';}
]){const c=compact();mutate(c.products[0].profile,c);assert.throws(()=>normalizeServiceCatalog(c));}
const oversized=compact(),product=oversized.products[0],sourceId=product.profile.facts[0].sourceIds[0];product.profile.facts=Array.from({length:8},(_,i)=>({id:'fact-'+i,title:'Fact',description:'x'.repeat(700),sourceIds:[sourceId]}));product.profile.links=[];
for(let i=0;i<6;i++){const id='large-document-'+i,url='https://reference.example.test/'+('x'.repeat(1950))+i;oversized.sources.push({id,url,revision:'one',checkedOn:'2026-10-19',access:'read',license:'Original metadata',note:''});product.sourceIds.push(id);product.profile.links.push({id,kind:'documentation',label:'Document',url,sourceIds:[id]});}
assert.ok(Buffer.byteLength(JSON.stringify(product.profile))>16*1024);assert.throws(()=>normalizeServiceCatalog(oversized));
const c=compact();const src=c.sources.find(s=>s.id===c.products[0].profile.facts[0].sourceIds[0]);src.id='undeclared';c.products[0].profile.facts[0].sourceIds=['undeclared'];assert.throws(()=>normalizeServiceCatalog(c));
});
test('static profiles escape all prose, visibly distinguish unread links, link prompts and exclude browser execution',async()=>{
const c=compact();c.products[0].profile.facts[0].title='';c.products[0].profile.reviewPrompts[0].prompt='';const html=renderCatalogProductProfile(c,'stripe-payments',{documentationBase:'/guide',headingLevel:4});const doc=new JSDOM(html).window.document;assert.equal(doc.querySelectorAll('script,img,input,button').length,0);assert.match(doc.querySelector('h5').textContent,/
renderCatalogProductProfile(c,'stripe-payments',{documentationBase:base}));
const graph=await build({stdin:{contents:"export {renderCatalogProductProfile} from 'notabenda/catalog/profile';",resolveDir:new URL('../../../',import.meta.url).pathname},bundle:true,write:false,metafile:true,platform:'browser'});assert.ok(Object.keys(graph.metafile.inputs).every(p=>!p.includes('catalog-data')&&!p.includes('catalog-project')&&!p.includes('catalog-seed')&&!p.includes('node:')));
});
test('privacy URL copying is explicit, preserves manual values and invalidates review on subsequent edits',async()=>{
const dom=new JSDOM('