// @requirements REQ-PRODUCT-SERVICE-CATALOG REQ-PRODUCT-SERVICE-DETECTION import {test} from 'node:test';import assert from 'node:assert/strict'; import {mkdtemp,writeFile,mkdir,symlink,readFile,rm} from 'node:fs/promises';import {tmpdir} from 'node:os';import {join,resolve} from 'node:path';import {execFileSync,spawnSync} from 'node:child_process';import {build} from 'esbuild'; import {normalizeServiceCatalog,searchServiceCatalog,createCatalogServiceDraft} from 'notabenda/catalog';import {serviceCatalogSeed} from 'notabenda/catalog/seed';import {scanProjectServices} from 'notabenda/catalog/project';import {validateSiteConfig} from 'notabenda'; const root=resolve(new URL('../../../',import.meta.url).pathname),cli=join(root,'packages/notabenda/dist/cli.js'); const small=()=>{const c=structuredClone(serviceCatalogSeed);c.products=c.products.filter(p=>['stripe-payments','aws-sdk','sentry-monitoring','cloudflare-workers'].includes(p.id));const providers=new Set(c.products.map(p=>p.providerId));c.providers=c.providers.filter(p=>providers.has(p.id));const sources=new Set(c.products.flatMap(p=>p.sourceIds));c.sources=c.sources.filter(s=>sources.has(s.id));return c;}; test('the offline seed preserves licensed unreviewed source metadata and backend reference candidates',()=>{ const c=normalizeServiceCatalog(serviceCatalogSeed);assert.equal(c.providers.length,363);assert.equal(c.products.length,402);assert.equal(c.products.reduce((sum,p)=>sum+p.storageHints.length,0),2278);assert.equal(c.sources.find(s=>s.id==='open-cookie-database').license,'Apache-2.0'); assert.ok(c.providers.every(p=>p.review==='unreviewed'));assert.ok(c.sources.filter(s=>s.id!=='open-cookie-database'&&!s.id.startsWith('browser-')&&!s.id.startsWith('profile-')).every(s=>s.checkedOn===null&&s.access==='reference-only')); const technical=c.sources.filter(s=>s.id.startsWith('browser-'));assert.equal(technical.length,9);assert.ok(technical.every(s=>s.checkedOn==='2026-10-19'&&s.access==='read'));assert.ok(c.products.every(p=>p.review==='unreviewed')); for(const query of ['hosting','database','email','identity','payments','monitoring','support','search'])assert.ok(searchServiceCatalog(c,query,{delivery:'backend'}).length,query); const fresh=normalizeServiceCatalog(c);fresh.providers[0].name='Changed';assert.notEqual(c.providers[0].name,'Changed'); }); test('catalog normalization rejects executable/accessor data, unknown fields, dangling sources/providers and duplicate identities',()=>{ for(const mutate of [c=>c.schemaVersion=2,c=>c.providers.push(c.providers[0]),c=>c.products[0].providerId='unknown',c=>c.products[0].sourceIds=['missing'],c=>c.products[0].identifiers[0].execute='code',c=>c.sources[0].url='javascript:alert(1)',c=>c.sources[0].checkedOn='2026-02-31',c=>c.products[0].identifiers[0].value='(a+)+$',c=>c.products[0].delivery=[]]){const c=small();mutate(c);assert.throws(()=>normalizeServiceCatalog(c));} let invoked=0;const c=small();Object.defineProperty(c,'getter',{enumerable:true,get(){invoked++;throw Error('must not execute');}});assert.throws(()=>normalizeServiceCatalog(c));assert.equal(invoked,0); const cyclic=small();cyclic.products[0].unknown=cyclic;assert.throws(()=>normalizeServiceCatalog(cyclic)); }); test('selection remains a detached explicit proposal and never invents site purposes, privacy links or locations',()=>{ const c=small(),draft=createCatalogServiceDraft(c,'stripe-payments',{id:'my-payments',purposeIds:['checkout'],location:'Operator-selected deployment'});assert.equal(draft.reviewRequired,true);assert.deepEqual(draft.service,{id:'my-payments',name:'Stripe payment libraries',provider:'Stripe',purposeIds:['checkout'],location:'Operator-selected deployment'});assert.equal(draft.service.resourceOrigins,undefined);assert.equal(draft.catalogSelection.product.id,'stripe-payments');draft.catalogSelection.product.name='Changed';assert.equal(c.products.find(p=>p.id==='stripe-payments').name,'Stripe payment libraries'); assert.throws(()=>createCatalogServiceDraft(c,'stripe-payments',{id:'mine',purposeIds:[],location:'Unknown'}));assert.throws(()=>createCatalogServiceDraft(c,'missing',{id:'mine',purposeIds:['checkout'],location:'Local'}));assert.throws(()=>searchServiceCatalog(c,'x',{limit:0}));assert.throws(()=>searchServiceCatalog(c,'x',{delivery:'unknown'})); }); async function fixture(run){const dir=await mkdtemp(join(tmpdir(),'notabenda-catalog-'));try{await run(dir);}finally{await rm(dir,{recursive:true,force:true});}} test('local scanning finds npm/import/Python/infrastructure candidates while excluding values, secrets, generated output and symlinks',async()=>fixture(async dir=>{ await writeFile(join(dir,'package.json'),JSON.stringify({dependencies:{stripe:'1.0.0'},devDependencies:{wrangler:'1'},scripts:{preinstall:'touch MUST_NOT_EXECUTE'}}));await writeFile(join(dir,'server.ts'),"import {S3Client} from '@aws-sdk/client-s3';\nconst secret='DO_NOT_EXPORT_SECRET_VALUE';\n");await writeFile(join(dir,'requirements.txt'),'sentry-sdk>=2\n');await writeFile(join(dir,'infra.tf'),'resource "aws_s3_bucket" "private-name" {}');await writeFile(join(dir,'.env'),"STRIPE_SECRET_KEY=DO_NOT_READ_OR_EXPORT\nimport 'stripe'\n");await mkdir(join(dir,'node_modules'));await writeFile(join(dir,'node_modules/ignored.ts'),"import 'stripe'");await symlink(join(dir,'server.ts'),join(dir,'link.ts')); const report=await scanProjectServices(dir,small());assert.equal(report.coverage.filesExamined,4);assert.deepEqual(report.findings.map(f=>f.productId),['aws-sdk','cloudflare-workers','sentry-monitoring','stripe-payments']);const value=JSON.stringify(report);assert.doesNotMatch(value,/DO_NOT_EXPORT|DO_NOT_READ|private-name|MUST_NOT_EXECUTE|link\.ts|ignored\.ts/);assert.ok(report.findings.flatMap(f=>f.evidence).some(e=>e.context==='development-dependency'));assert.ok(report.findings.find(f=>f.productId==='aws-sdk').evidence.some(e=>e.ecosystem==='terraform'));await assert.rejects(readFile(join(dir,'MUST_NOT_EXECUTE'))); })); test('Git ignore-aware selection respects ignored private trees and disables configured filesystem-monitor execution',async()=>fixture(async dir=>{ execFileSync('git',['init','--quiet'],{cwd:dir});await writeFile(join(dir,'.gitignore'),'ignored/\n');await mkdir(join(dir,'ignored'));await writeFile(join(dir,'ignored/leak.ts'),"import 'stripe';");await writeFile(join(dir,'visible.ts'),"import 'stripe';");const monitor=join(dir,'monitor.sh');await writeFile(monitor,'#!/bin/sh\ntouch '+join(dir,'EXECUTED')+'\n',{mode:0o700});execFileSync('git',['config','core.fsmonitor',monitor],{cwd:dir}); const report=await scanProjectServices(dir,small());assert.equal(report.coverage.selection,'git-ignore-aware');assert.equal(report.findings.length,1);assert.deepEqual(report.findings[0].evidence.map(e=>e.file),['visible.ts']);await assert.rejects(readFile(join(dir,'EXECUTED'))); })); test('oversized or unsupported files make coverage limits explicit and prefix confusers do not identify a product',async()=>fixture(async dir=>{ await writeFile(join(dir,'large.ts'),"import 'stripe';"+'x'.repeat(270000));await writeFile(join(dir,'confuser.ts'),"import '@aws-sdk-malicious/client'; import 'stripe-fake';");const r=await scanProjectServices(dir,small());assert.equal(r.coverage.limited,true);assert.deepEqual(r.findings,[]); })); test('the catalog npx workflow searches references and scans without a site config, preserving exclusive private outputs',async()=>fixture(async dir=>{ await writeFile(join(dir,'package.json'),'{"dependencies":{"stripe":"1"}}');const search=spawnSync(process.execPath,[cli,'catalog','search','stripe','--delivery','backend','--json'],{cwd:dir,encoding:'utf8'});assert.equal(search.status,0,search.stderr);assert.equal(JSON.parse(search.stdout).results[0].product.id,'stripe-payments'); const out=join(dir,'review');let r=spawnSync(process.execPath,[cli,'discover','--project',dir,'--out-dir',out,'--json'],{cwd:dir,encoding:'utf8'});assert.equal(r.status,0,r.stderr);assert.equal(JSON.parse(r.stdout).findings[0].productId,'stripe-payments');assert.match(await readFile(join(out,'.gitignore'),'utf8'),/\*/); r=spawnSync(process.execPath,[cli,'discover','--project',dir,'--out-dir',out],{cwd:dir,encoding:'utf8'});assert.notEqual(r.status,0);for(const flags of [['--project',dir,'--capture','capture.json'],['--project',dir,'--project',dir],['--project',dir,'--limit','5']])assert.notEqual(spawnSync(process.execPath,[cli,'discover',...flags],{cwd:dir,encoding:'utf8'}).status,0); })); test('ordinary consent delivery excludes catalog data and project scanning from its actual browser graph',async()=>{ const result=await build({stdin:{contents:"export {createConsentRuntime,mountControls} from 'notabenda/consent';",resolveDir:root},bundle:true,write:false,metafile:true,platform:'browser',format:'esm'});assert.ok(Object.keys(result.metafile.inputs).every(p=>!p.includes('/catalog')&&!p.includes('node:'))); }); test('npx project PATH shims cannot replace the system Git used for file selection',async()=>fixture(async dir=>{ await writeFile(join(dir,'package.json'),'{"dependencies":{"stripe":"1"}}');await mkdir(join(dir,'bin'));await writeFile(join(dir,'bin/git'),'#!/bin/sh\necho EXECUTED > "'+join(dir,'SHIM_EXECUTED')+'"\nexit 128\n',{mode:0o700}); const r=spawnSync(process.execPath,[cli,'discover','--project',dir,'--json'],{cwd:dir,encoding:'utf8',env:{...process.env,PATH:join(dir,'bin')+':'+process.env.PATH}});assert.equal(r.status,0,r.stderr);assert.equal(JSON.parse(r.stdout).findings[0].productId,'stripe-payments');await assert.rejects(readFile(join(dir,'SHIM_EXECUTED'))); })); test('catalog JSON parse failures never echo malformed source values to the terminal',async()=>fixture(async dir=>{ const file=join(dir,'catalog.json');await writeFile(file,'{"secret":"DO_NOT_ECHO_THIS_VALUE",');const r=spawnSync(process.execPath,[cli,'catalog','search','stripe','--catalog',file],{cwd:dir,encoding:'utf8'});assert.notEqual(r.status,0);assert.doesNotMatch(r.stderr,/DO_NOT_ECHO_THIS_VALUE/);assert.match(r.stderr,/bounded UTF-8 JSON/); })); test('a reviewed ordinary service proposal feeds existing configuration and script-free legal release rendering',async()=>{ const {fixture}=await import('./helpers.mjs'),{compileLegalDocuments}=await import('notabenda/content');const config=await fixture({reviewed:true});const draft=createCatalogServiceDraft(serviceCatalogSeed,'stripe-payments',{id:'catalog-payments',purposeIds:[config.purposes[0].id],location:'Operator-declared processing location',provider:'Operator-confirmed Stripe entity',privacyUrl:'https://stripe.com/privacy'});config.services.push(draft.service); assert.equal(validateSiteConfig(config).valid,true);const release=compileLegalDocuments(config);assert.match(release.files['privacy/index.html'],/Stripe payment libraries/);assert.match(release.files['privacy/index.html'],/Operator-confirmed Stripe entity/);assert.doesNotMatch(release.files['privacy/index.html'],/