// @requirements REQ-PRODUCT-SETUP REQ-PRODUCT-SERVICE-CATALOG REQ-PRODUCT-SERVICE-DETECTION import test from 'node:test'; import assert from 'node:assert/strict'; import {mkdtemp,readFile,writeFile,mkdir,rm,stat,symlink} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {spawnSync} from 'node:child_process'; import {serviceCatalogSeed} from 'notabenda/catalog/seed'; import {reviewSetupCatalog} from '../dist/setup-catalog-cli.js'; import {createSetupPlan} from 'notabenda/setup'; import {createCatalogSelectionPlan} from 'notabenda/catalog/selection'; import {fixture} from './helpers.mjs'; const cli=new URL('../dist/cli.js',import.meta.url).pathname; const answers={schemaVersion:1,markets:[],answers:{'personal-data':{choice:'yes',justification:'PRIVATE_SETUP_NOTE'}}}; function catalog(){const c=structuredClone(serviceCatalogSeed);c.products=c.products.filter(p=>p.id==='stripe-payments');const providers=new Set(c.products.map(p=>p.providerId));c.providers=c.providers.filter(p=>providers.has(p.id));return c;} async function project(cwd){await mkdir(join(cwd,'project'));await writeFile(join(cwd,'project/package.json'),JSON.stringify({dependencies:{stripe:'PRIVATE_DEPENDENCY_VERSION'},scripts:{prepare:'touch DO_NOT_RUN'}}));await writeFile(join(cwd,'project/.env'), 'PRIVATE_ENV_SECRET=never-read');} function run(cwd,...args){return spawnSync(process.execPath,[cli,...args],{cwd,encoding:'utf8',timeout:7000});} function askSequence(steps){const prompts=[];return {prompts,ask:async(prompt)=>{prompts.push(prompt);const next=steps.shift();assert.ok(next,'unexpected prompt '+prompt);assert.match(prompt,next[0]);return next[1];},complete:()=>assert.equal(steps.length,0)};} async function captureLogs(work){const logs=[],prior=console.log;console.log=(...values)=>logs.push(values.join(' '));try{return {result:await work(),text:logs.join('\n')};}finally{console.log=prior;}} const reportFor=(c)=>({schemaVersion:1,kind:'project-service-discovery',catalog:{id:c.id,revision:c.revision},coverage:{selection:'built-in-skips',filesExamined:1,bytesExamined:50,identifierTokensExamined:1,omittedEvidence:0,skippedFiles:0,limited:false},findings:c.products.map(p=>({productId:p.id,productName:p.name,providerId:p.providerId,providerName:c.providers.find(v=>v.id===p.providerId).name,reviewRequired:true,evidence:[{file:'package.json',ruleId:p.identifiers[0].id,ecosystem:'npm',identifier:p.identifiers[0].value,match:'exact',context:'dependency',explanation:p.identifiers[0].explanation}]})),notes:[]}); test('wizard --project exports private evidence and preserves imported facts without selecting services from noninteractive answers',async()=>{ const cwd=await mkdtemp(join(tmpdir(),'notabenda-wizard-project-'));try{await project(cwd);const config=await fixture({reviewed:true});config.private={note:'PRIVATE_IMPORTED_NOTE'};await writeFile(join(cwd,'config.json'),JSON.stringify(config));await writeFile(join(cwd,'answers.json'),JSON.stringify(answers));await writeFile(join(cwd,'catalog.json'),JSON.stringify(catalog())); const result=run(cwd,'wizard','--answers','answers.json','--config','config.json','--project','project','--catalog','catalog.json','--out-dir','review');assert.equal(result.status,0,result.stderr);assert.doesNotMatch(result.stdout,/PRIVATE_|package.json/); const output=JSON.parse(await readFile(join(cwd,'review/notabenda.config.json'),'utf8')),report=JSON.parse(await readFile(join(cwd,'review/setup.private.json'),'utf8')),discovery=JSON.parse(await readFile(join(cwd,'review/discovery.private.json'),'utf8')); assert.deepEqual(output.services,config.services);assert.deepEqual(output.purposes,config.purposes);assert.deepEqual(output.storage,config.storage);assert.deepEqual(output.consent,config.consent);assert.equal((report.catalogSelections??[]).length,0);assert.equal(discovery.findings[0].productId,'stripe-payments');assert.equal(discovery.findings[0].evidence[0].file,'package.json');assert.deepEqual(discovery.catalog,{id:catalog().id,revision:catalog().revision});assert.doesNotMatch(JSON.stringify(output),/PRIVATE_|project-service-discovery/);assert.doesNotMatch(JSON.stringify(discovery),/PRIVATE_|\.env/);assert.match(JSON.stringify(report),/PRIVATE_SETUP_NOTE/);assert.ok(Object.values(output.documents).every(d=>d.review.status==='draft')); assert.equal((await stat(join(cwd,'review'))).mode&0o777,0o700);assert.equal((await stat(join(cwd,'review/discovery.private.json'))).mode&0o777,0o600);assert.equal(await readFile(join(cwd,'review/.gitignore'),'utf8'),'setup.private.json\ndiscovery.private.json\n');await assert.rejects(stat(join(cwd,'project/DO_NOT_RUN'))); const prior=await readFile(join(cwd,'review/notabenda.config.json'),'utf8');assert.equal(run(cwd,'wizard','--answers','answers.json','--project','project','--out-dir','review').status,1);assert.equal(await readFile(join(cwd,'review/notabenda.config.json'),'utf8'),prior); }finally{await rm(cwd,{recursive:true,force:true});} }); test('detected candidates require explicit actual-use facts and confirmation, then reuse the exact private selection pins',async()=>{ const c=catalog(),base=await fixture({reviewed:true}),before=structuredClone(base),sequence=askSequence([[/Review project service candidates/,'yes'],[/Project product ID/,'stripe-payments'],[/Unique local service ID/,'checkout'],[/Actual provider entity/,'Operator-confirmed payment entity'],[/Actual processing location/,'Reviewed location'],[/Declared purpose IDs/,'enquiries'],[/Verified provider privacy/,'https://stripe.com/privacy'],[/Supporting facts/,'PRIVATE_SELECTED_REASON'],[/Confirm the actual/,'yes'],[/Project product ID/,''],[/Search the service reference/,'no']]); const {result,text}=await captureLogs(()=>reviewSetupCatalog(base,[],sequence.ask,{catalog:c,report:reportFor(c)}));sequence.complete();assert.deepEqual(base,before);assert.deepEqual(result.base.services.slice(0,-1),base.services);assert.equal(result.base.services.at(-1).id,'checkout');assert.equal(result.selections[0].reference.products[0].id,'stripe-payments');assert.equal(result.selections[0].reference.revision,c.revision);assert.equal(result.selections[0].justification,'PRIVATE_SELECTED_REASON');assert.deepEqual(result.selections[0].purposes,base.purposes.filter(p=>p.id==='enquiries'));assert.match(text,/package.json · dependency/);assert.match(text,/not retrieved or verified/);assert.doesNotMatch(text,/PRIVATE_SELECTED_REASON/); const plan=createSetupPlan(answers,{config:result.base,catalogSelections:result.selections});assert.equal(plan.config.consent,base.consent);assert.doesNotMatch(JSON.stringify(plan.config),/PRIVATE_SELECTED_REASON|project-service-discovery/);assert.equal(plan.report.catalogSelections[0].justification,'PRIVATE_SELECTED_REASON'); }); test('a new scan preserves resumed prior source pins even when the current catalog revision and product label changed',async()=>{ const cwd=await mkdtemp(join(tmpdir(),'notabenda-wizard-pins-'));try{await project(cwd);const c=catalog(),base=await fixture({reviewed:true}),selected=createCatalogSelectionPlan(base,c,{productId:'stripe-payments',serviceId:'checkout',purposeIds:['enquiries'],provider:'Reviewed entity',location:'Reviewed location',justification:'PRIVATE_PREVIOUS_REVIEW',confirmed:true}),plan=createSetupPlan(answers,{config:selected.config,catalogSelections:[selected.selection]}); await writeFile(join(cwd,'config.json'),JSON.stringify(plan.config));await writeFile(join(cwd,'answers.json'),JSON.stringify(plan.report));c.revision='later-reference';c.products[0].name='Updated reference label';await writeFile(join(cwd,'catalog.json'),JSON.stringify(c)); const result=run(cwd,'wizard','--answers','answers.json','--config','config.json','--project','project','--catalog','catalog.json','--out-dir','review');assert.equal(result.status,0,result.stderr);const report=JSON.parse(await readFile(join(cwd,'review/setup.private.json'),'utf8')),discovery=JSON.parse(await readFile(join(cwd,'review/discovery.private.json'),'utf8'));assert.deepEqual(report.catalogSelections,plan.report.catalogSelections);assert.equal(discovery.catalog.revision,'later-reference');assert.equal(discovery.findings[0].productName,'Updated reference label');assert.doesNotMatch(result.stdout,/PRIVATE_PREVIOUS_REVIEW/);assert.equal(JSON.parse(await readFile(join(cwd,'review/notabenda.config.json'),'utf8')).services.at(-1).name,selected.selection.service.name); }finally{await rm(cwd,{recursive:true,force:true});} }); test('manual search after a scan uses the explicitly selected custom catalog and the same confirmation boundary',async()=>{ const c=catalog();c.products[0].aliases.push('private-example-gateway');c.revision='custom-reference';const report=reportFor(c);report.findings=[];const base=await fixture({reviewed:true}),sequence=askSequence([[/Search the service reference/,'yes'],[/Provider, product or category/,'private-example-gateway'],[/Product ID to review/,'stripe-payments'],[/Unique local service/,'custom-checkout'],[/Actual provider/,'Confirmed entity'],[/Actual processing/,'Confirmed location'],[/Declared purpose/,'enquiries'],[/Verified provider/,''],[/Supporting facts/,'Private custom review'],[/Confirm the actual/,'yes'],[/Provider, product or category/,'']]);const {result}=await captureLogs(()=>reviewSetupCatalog(base,[],sequence.ask,{catalog:c,report}));sequence.complete();assert.equal(result.selections[0].reference.revision,'custom-reference');assert.equal(result.base.services.at(-1).id,'custom-checkout'); }); test('declined and invalid candidate reviews leave the draft and prior pins intact; undetected IDs do not open a proposal',async()=>{ const c=catalog(),base=await fixture({reviewed:true});const selection=createCatalogSelectionPlan(base,c,{productId:'stripe-payments',serviceId:'prior-checkout',purposeIds:['enquiries'],provider:'Prior entity',location:'Prior location',justification:'Private prior review',confirmed:true}); const sequence=askSequence([[/Review project service candidates/,'yes'],[/Project product ID/,'not-detected'],[/Project product ID/,'stripe-payments'],[/Unique local/,'declined'],[/Actual provider/,'New entity'],[/Actual processing/,'New location'],[/Declared purpose/,'enquiries'],[/Verified provider/,''],[/Supporting facts/,'Private proposed reason'],[/Confirm the actual/,'no'],[/Project product ID/,'stripe-payments'],[/Unique local/,'invalid'],[/Actual provider/,'New entity'],[/Actual processing/,'New location'],[/Declared purpose/,'missing'],[/Verified provider/,''],[/Supporting facts/,'Private invalid reason'],[/Confirm the actual/,'yes'],[/Project product ID/,''],[/Search the service reference/,'no']]); const {result,text}=await captureLogs(()=>reviewSetupCatalog(selection.config,[selection.selection],sequence.ask,{catalog:c,report:reportFor(c)}));sequence.complete();assert.equal(result.base,selection.config);assert.deepEqual(result.selections,[selection.selection]);assert.match(text,/Choose a detected product/);assert.match(text,/Existing selected local services: prior-checkout/);assert.match(text,/Selection was not added/);assert.doesNotMatch(text,/Private proposed reason|Private invalid reason/); }); test('skipping discovery review does not invent a service or require purposes',async()=>{ const c=catalog(),sequence=askSequence([[/Review project service candidates/,''],[/Search the service reference/,'no']]);const {result}=await captureLogs(()=>reviewSetupCatalog(undefined,[],sequence.ask,{catalog:c,report:reportFor(c)}));sequence.complete();assert.equal(result.base,undefined);assert.deepEqual(result.selections,[]); const missing=askSequence([[/Review project service candidates/,'yes'],[/Project product ID/,'stripe-payments'],[/Project product ID/,''],[/Search the service reference/,'no']]);const {result:unchanged,text}=await captureLogs(()=>reviewSetupCatalog(undefined,[],missing.ask,{catalog:c,report:reportFor(c)}));missing.complete();assert.equal(unchanged.base,undefined);assert.match(text,/Declare actual processing purposes/); }); test('project candidates paginate without hiding later IDs or changing their evidence',async()=>{ const c=catalog(),product=c.products[0];c.products=Array.from({length:13},(_,i)=>({...structuredClone(product),id:'candidate-'+i,identifiers:[{...product.identifiers[0],id:'candidate-rule-'+i,value:'sdk-'+i}]}));const report=reportFor(c),before=structuredClone(report);const sequence=askSequence([[/Review project service candidates/,'yes'],[/Project product ID/,'next'],[/Project product ID/,'back'],[/Project product ID/,''],[/Search the service reference/,'no']]);const {text}=await captureLogs(()=>reviewSetupCatalog(undefined,[],sequence.ask,{catalog:c,report}));sequence.complete();assert.match(text,/candidates 1–12 of 13/);assert.match(text,/candidates 13–13 of 13/);assert.deepEqual(report,before); }); test('wizard project options fail before output creation on invalid flags, invalid answers, unreadable catalogs and nonterminal use',async()=>{ const cwd=await mkdtemp(join(tmpdir(),'notabenda-wizard-options-'));try{await project(cwd);await writeFile(join(cwd,'answers.json'),JSON.stringify(answers));await writeFile(join(cwd,'bad.json'),'{"PRIVATE_BAD_JSON"');await writeFile(join(cwd,'invalid.json'),'{"schemaVersion":2}');await writeFile(join(cwd,'invalid-utf8.json'),Buffer.from([0xff])); for(const args of [['wizard','--answers','answers.json','--catalog','bad.json'],['wizard','--project','project'],['wizard','--answers','invalid.json','--project','missing'],['wizard','--answers','bad.json','--project','missing'],['wizard','--answers','invalid-utf8.json','--project','missing'],['wizard','--answers','answers.json','--project','project','--catalog','bad.json'],['check','--project','project'],['init','--catalog','bad.json'],['wizard','--answers','answers.json','--project','project','--project','project']]){const result=run(cwd,...args);assert.equal(result.error,undefined);assert.equal(result.status,1);assert.doesNotMatch(result.stderr,/PRIVATE_BAD_JSON/);await assert.rejects(stat(join(cwd,'notabenda-setup')));} await symlink(join(cwd,'answers.json'),join(cwd,'linked.json'));assert.equal(run(cwd,'wizard','--answers','linked.json').status,1);await writeFile(join(cwd,'huge.json'),' '.repeat(512*1024+1));assert.equal(run(cwd,'wizard','--answers','huge.json').status,1); }finally{await rm(cwd,{recursive:true,force:true});} }); test('setup/catalog JSON and tracked project inputs reject named pipes without waiting for writers',{skip:process.platform==='win32'},async()=>{ const cwd=await mkdtemp(join(tmpdir(),'notabenda-wizard-pipe-'));try{await writeFile(join(cwd,'answers.json'),JSON.stringify(answers));await project(cwd);assert.equal(spawnSync('mkfifo',[join(cwd,'fifo.json')]).status,0); for(const args of [['wizard','--answers','fifo.json'],['wizard','--answers','answers.json','--config','fifo.json'],['wizard','--answers','answers.json','--project','project','--catalog','fifo.json'],['catalog','search','stripe','--catalog','fifo.json']]){const result=run(cwd,...args);assert.equal(result.error,undefined);assert.equal(result.status,1);} const git=(...args)=>spawnSync('/usr/bin/git',args,{cwd:join(cwd,'project'),encoding:'utf8'});assert.equal(git('init','--quiet').status,0);await writeFile(join(cwd,'project/blocked.ts'),'import Stripe from "stripe";');assert.equal(git('add','blocked.ts','package.json').status,0);await rm(join(cwd,'project/blocked.ts'));assert.equal(spawnSync('mkfifo',[join(cwd,'project/blocked.ts')]).status,0);const result=run(cwd,'wizard','--answers','answers.json','--project','project','--out-dir','review');assert.equal(result.error,undefined);assert.equal(result.status,0,result.stderr);const report=JSON.parse(await readFile(join(cwd,'review/discovery.private.json'),'utf8'));assert.equal(report.coverage.limited,true);assert.ok(report.coverage.skippedFiles>=1);assert.ok(report.findings.some(f=>f.productId==='stripe-payments')); }finally{await rm(cwd,{recursive:true,force:true});} });