Initial commit: Converge portfolio rationalization app

Next.js app for mapping a customer's competitive tool landscape onto our
portfolio and building the consolidation business case: assessment
workbench, customer report and Word export, product catalog, competitive
knowledge base, dossier import, shareable landscape questionnaires with a
review inbox, tool-footprint and business-unit value analysis, and an
implementation timeline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 11:48:42 -04:00
commit d5c77543e8
64 changed files with 30861 additions and 0 deletions

12
scripts/build-test-lib.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Compiles the pure-logic libs so tests can exercise the real source under Node.
set -euo pipefail
cd "$(dirname "$0")/.."
rm -rf .test-build
npx --yes tsc src/lib/dossier.ts src/lib/kb.ts src/lib/scoring.ts src/lib/client.ts \
--outDir .test-build --target ES2022 --module esnext \
--moduleResolution bundler --skipLibCheck
# tsc emits extensionless relative imports; Node ESM needs the .js suffix.
for f in .test-build/*.js; do
perl -pi -e 's{(from\s+")(\./[^"]+?)(")}{$1.$2.".js".$3}ge unless /\.js"/' "$f"
done

View File

@@ -0,0 +1,112 @@
GUIDEWELL BLUE CROSS BLUE SHIELD OF FLORIDA — Strategic Account Dossier
Complete account intelligence — every field TechDirector tracks for this account.
Executive Summary
Company: GUIDEWELL BLUE CROSS BLUE SHIELD OF FLORIDA
Industry: Health Care Equipment & Services
Employees: Not available
Annual Revenue: Not available
Classification: Tier 1 / Strategic
Priority Flags: MVP (Most Valuable Partner — priority account requiring focused attention)
Account Status: Active
Account Sentiment: Neutral
Renewal Timeframe: 2028 Q 2
Renewal Risk: 5
Composite Scores
Account Health Score: n/a/100
- Subscores: Data Completeness 0 / VCF Footprint 8 / Deployment Rate 30 / Engagement 17 / Risk Profile 14
Intelligence Score: 98/100
TAM Value Score: 39/100
Public Company Profile
• Area: AM-SOUTHEAST
• District: SE-SUNSHINE
• Account Website: guidewell.com
• Public/Private: Private
VCF Status
VCF Plan Health: Yellow
VCF Version: vSphere 8.x
VCF Cores Entitled: 35768
Cores by Version: vSphere 8.x: 35768
Deployment Model: Standard
VCF Path: Brownfield
Adoption Stage: No VCF Adoption
VCF 9 Plans: VCF 9.1.x
VCF 9 Blockers: No Blockers
Infrastructure & Cloud Strategy
Cloud Strategy: Hybrid (Public+Private)
Primary Hyperscaler: AWS, Azure
Workload Distribution: 25% Public Cloud, 50% Private Cloud, 25% Mainframe
Compute Vendor: HPE
Storage Platform: Everpure (Pure)
Network Vendor: Cisco
Firewall Vendor: Zscaler
Load Balancer Vendor: F5
Product Consumption
Overall Deployment Rate: 76% (6574253 / 8691992)
Products
• VSPHERE: Entitled 35768, Deployed 33400, Adoption High
• VSAN: Entitled 480, Deployed 0, Adoption Shelfware
• NSX: Entitled 0, Deployed 0, Adoption Shelfware
• VCF OPERATIONS: Entitled 10000, Deployed 0, Adoption Shelfware
• VCF SDDC: Entitled 0, Deployed 0, Adoption Shelfware
• VCF AUTOMATION: Entitled 0, Deployed 0, Adoption Shelfware
• VKS: Entitled 0, Deployed 0, Adoption Shelfware
Applications
ERP (4)
• Peoplesoft FSCM
• PeopleSoft HCM
• SAP Finance
• Oracle HCM
Financial (8)
• Diamond
• QNXT
• HITS/CAPS
• AccumWise/Accumuilators
• PBM Prime
• PMT (Product Management Tools)
• Enrollpoint
• Saphire/NASCO
Other (6)
• Florida Blue Portal
• Omnissa Virtual Desktop
• Prior Authorization AI
• IBM API Connect / Datapower
• GWDP (Guidewell Data Platform)
• Online Health/ Well Frame
SaaS (1)
• Member Website/Mobile App (MWS)
Tech Debt Signals
Legacy health insurance claims systems. Mainframe at 15% for core claims processing. GuideWell subsidiary systems may be on separate infrastructure stacks.
Cloud Strategy Assessment
80% public cloud is the highest ratio for any health insurance account in the portfolio. This extreme cloud adoption for a HIPAA-regulated entity suggests either a cloud-native modernization strategy or that most workloads are SaaS rather than IaaS.
DevOps Platform Assessment
OpenShift Pipelines for container CI/CD. Limited DevOps tooling documented.

94
scripts/seed-kb.mjs Normal file
View File

@@ -0,0 +1,94 @@
/**
* Builds data/knowledge-base.json from the catalog's `replaces` lists.
*
* Each competitor string named by a product becomes one entry mapped to every
* product that claims to displace it. Scores start at neutral defaults and are
* refined automatically as real assessments are saved.
*
* Re-running is safe: existing entries keep their edits, observations and pins,
* and only gain newly-discovered product mappings.
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const CATALOG = "data/catalog.json";
const KB = "data/knowledge-base.json";
const MULTIWORD_VENDORS = [
"Red Hat", "Palo Alto", "Check Point", "Micro Focus", "Trend Micro", "Arctic Wolf", "Red Canary",
"Ping Identity", "One Identity", "Salt Security", "Noname Security", "Traceable AI", "Aqua Security",
"Airlock Digital", "Abnormal Security", "Menlo Security", "Recorded Future", "Scale Computing",
"Wind River", "Software AG", "Cato Networks", "Zero Networks", "Digital Guardian", "Snow Software",
"Cardinal Commerce", "Duo Security", "Crunchy Data", "Morpheus Data", "Sumo Logic", "Prisma Cloud",
"Amazon Web Services", "Google Cloud", "Data Theorem",
];
const splitVendor = (label) => {
const clean = label.replace(/\s+/g, " ").trim();
for (const v of MULTIWORD_VENDORS) {
if (clean.toLowerCase().startsWith(v.toLowerCase() + " ")) return { vendor: v, product: clean.slice(v.length).trim() };
if (clean.toLowerCase() === v.toLowerCase()) return { vendor: v, product: "" };
}
const parts = clean.split(" ");
return parts.length === 1 ? { vendor: clean, product: "" } : { vendor: parts[0], product: parts.slice(1).join(" ") };
};
const NON_COMPETITIVE = [
/\(status quo\)/i, /\(internal alternative\)/i, /status quo/i,
/\bDIY\b/i, /\bscripts?\b/i, /spreadsheets/i, /\bmanual\b/i,
/^custom /i, /^native /i, /^internal /i, /^self-managed/i, /^unsupported/i,
/^physical /i, /^consulting/i, /^fragmented/i, /^serialized/i, /^shared /i,
/^point[- ]tool/i, /^point IAM/i, /^no\b/i, /^none\b/i, /^password-only/i,
/^display statements/i, /^unencrypted/i, /^green[- ]screen/i, /^cron\b/i,
/rewrite|redevelopment/i, /^migration to /i, /^rehost/i, /^low-code platforms/i,
/^multiple /i, /^various /i, /^hand-written/i, /^ISPF /i, /^unsupported community/i,
];
const isCompetitive = (label) => !NON_COMPETITIVE.some((re) => re.test(label));
const kbId = (label) =>
label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 72) || "entry";
const catalog = JSON.parse(readFileSync(CATALOG, "utf8"));
const existing = existsSync(KB) ? JSON.parse(readFileSync(KB, "utf8")) : { version: 1, entries: [] };
const byId = new Map(existing.entries.map((e) => [e.id, e]));
const now = new Date().toISOString();
let created = 0, enriched = 0;
for (const p of catalog.products) {
for (const raw of p.replaces ?? []) {
const label = raw.replace(/\s+/g, " ").trim();
if (!label) continue;
const id = kbId(label);
let e = byId.get(id);
if (!e) {
const { vendor, product } = splitVendor(label);
e = {
id, label, vendor, product, aliases: [], domain: p.domain,
productIds: [], capability: 4, switchEase: 3, timeToRoiMonths: 12,
savingsPct: 50, migrationCost: 0, unitLabel: "", risks: "", notes: "",
competitive: isCompetitive(label),
pinned: false, origin: "catalog", observations: [],
createdAt: now, updatedAt: now,
};
byId.set(id, e);
created++;
}
// Only add mappings; never strip a mapping a human curated.
if (!e.productIds.includes(p.id)) {
e.productIds.push(p.id);
if (e.origin === "catalog") enriched++;
}
if (!e.domain) e.domain = p.domain;
}
}
const entries = [...byId.values()].sort((a, b) => a.label.localeCompare(b.label));
writeFileSync(KB, JSON.stringify({ version: 1, updatedAt: now, entries }, null, 2) + "\n");
// Backfill the flag for entries written before it existed.
for (const e of entries) if (e.competitive === undefined) e.competitive = isCompetitive(e.label);
const mapped = entries.filter((e) => e.productIds.length > 0).length;
const rivals = entries.filter((e) => e.competitive).length;
console.log(`knowledge base: ${entries.length} entries (${created} new), ${mapped} mapped, ${rivals} true competitors, ${enriched} mappings added`);

27
scripts/test-client.mjs Normal file
View File

@@ -0,0 +1,27 @@
// Exercises the exact regression: an empty 200 body must throw, not resolve to undefined.
const { getJson, HttpError } = await import('/tmp/prt-test/client.mjs');
let pass = 0, fail = 0;
const check = (name, ok) => { ok ? (pass++, console.log(' PASS', name)) : (fail++, console.log(' FAIL', name)); };
// 1. Real endpoint still works.
const cat = await getJson('http://127.0.0.1:3500/api/catalog');
check('catalog loads', cat.products.length === 179);
// 2. Empty 200 body -> throws (previously: JSON.parse crash that killed Promise.all).
globalThis.fetch = async () => new Response('', { status: 200 });
try { await getJson('/empty'); check('empty body throws', false); }
catch (e) { check('empty body throws HttpError', e instanceof HttpError && /Empty response/.test(e.message)); }
// 3. 404 surfaces its status so the caller can show "not found" instead of an error.
globalThis.fetch = async () => new Response(JSON.stringify({error:'Not found'}), { status: 404 });
try { await getJson('/missing'); check('404 throws', false); }
catch (e) { check('404 carries status', e.status === 404 && e.message === 'Not found'); }
// 4. A transient failure is retried once and then succeeds.
let n = 0;
globalThis.fetch = async () => (++n === 1 ? new Response('', { status: 200 }) : new Response(JSON.stringify({ok:true}), { status: 200 }));
check('retries once then succeeds', (await getJson('/flaky')).ok === true && n === 2);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);

64
scripts/test-dossier.mjs Normal file
View File

@@ -0,0 +1,64 @@
// Parser tests run against the compiled lib so they exercise the real source.
const { parseDossier, buildImportResult } = await import('../.test-build/dossier.js');
import { readFileSync } from 'node:fs';
const raw = readFileSync(new URL('./fixtures/dossier-sample.txt', import.meta.url), 'utf8');
const kb = JSON.parse(readFileSync(new URL('../data/knowledge-base.json', import.meta.url), 'utf8'));
const catalog = JSON.parse(readFileSync(new URL('../data/catalog.json', import.meta.url), 'utf8'));
let pass=0, fail=0;
const t=(n,ok,extra='')=>{ok?(pass++,console.log(' PASS',n)):(fail++,console.log(' FAIL',n,extra))};
const intel = parseDossier(raw, 'https://docs.google.com/document/d/ABC/edit');
t('company', intel.company === 'GUIDEWELL BLUE CROSS BLUE SHIELD OF FLORIDA', intel.company);
t('industry', intel.industry === 'Health Care Equipment & Services', intel.industry);
t('classification', intel.classification === 'Tier 1 / Strategic');
t('renewal timeframe', intel.renewalTimeframe === '2028 Q 2');
t('"Not available" is dropped, not stored', !JSON.stringify(intel).includes('Not available'));
t('source url retained', intel.sourceUrl.includes('docs.google.com'));
t('infrastructure vendors', intel.infrastructure.length === 5, JSON.stringify(intel.infrastructure));
t('load balancer is F5', intel.infrastructure.some(f=>f.label==='Load Balancer Vendor' && f.value==='F5'));
t('cloud facts', intel.cloud.length === 3);
t('vcf facts captured', intel.vcf.some(f=>f.label==='VCF Cores Entitled' && f.value==='35768'));
t('consumption rows', intel.consumption.length === 7, String(intel.consumption.length));
const vsphere = intel.consumption.find(c=>c.product==='VSPHERE');
t('vsphere entitled/deployed parsed', vsphere.entitled===35768 && vsphere.deployed===33400 && vsphere.adoption==='High');
t('deployment rate', intel.deploymentRate.startsWith('76%'));
t('apps parsed across categories', intel.applications.length === 19, String(intel.applications.length));
t('app category assigned', intel.applications.find(a=>a.name==='SAP Finance')?.category === 'ERP');
t('bullet chars stripped', intel.applications.every(a=>!/^[•\-\s]/.test(a.name)));
t('narratives captured', intel.narratives.length === 3, JSON.stringify(intel.narratives.map(n=>n.heading)));
t('narrative body', /Mainframe at 15%/.test(intel.narratives.find(n=>n.heading==='Tech Debt Signals').body));
// ---- proposals ----
const res = buildImportResult(raw, '', kb, catalog);
const by = (l) => res.proposals.find(p=>p.label.toLowerCase().includes(l));
t('F5 proposed', !!by('f5'), res.proposals.map(p=>p.label).join(' | '));
t('F5 maps to Avi', by('f5')?.productIds.includes('ans-avi'));
t('Zscaler proposed', !!by('zscaler'));
t('IBM API Connect proposed', !!by('api connect') || !!by('ibm api'));
t('no duplicate KB entries', new Set(res.proposals.map(p=>p.kbEntryId)).size === res.proposals.length);
t('all mapped product ids are real', res.proposals.every(p=>p.productIds.every(id=>catalog.products.some(x=>x.id===id))));
t('proposals carry evidence', res.proposals.every(p=>p.evidence.includes(':')));
t('sorted by confidence', res.proposals.map(p=>({high:0,medium:1,low:2})[p.confidence]).every((v,i,a)=>i===0||a[i-1]<=v));
t('shelfware = entitled>0 & deployed=0', res.shelfware.length===2 && res.shelfware.every(c=>c.entitled>0&&c.deployed===0), JSON.stringify(res.shelfware.map(c=>c.product)));
t('vsphere not shelfware', !res.shelfware.some(c=>c.product==='VSPHERE'));
// Garbage in must not throw.
const empty = buildImportResult('hello world', '', kb, catalog);
t('junk input degrades gracefully', empty.proposals.length===0 && empty.warnings.length>0);
console.log(`\n${pass} passed, ${fail} failed`);
console.log(`\nProposals from the sample dossier (${res.proposals.length}):`);
for (const p of res.proposals) {
const names = p.productIds.map(id=>catalog.products.find(x=>x.id===id)?.name).join(', ');
console.log(` [${p.confidence.padEnd(6)}] ${p.label.padEnd(26)} → ${names || '(unmapped)'}`);
}
process.exit(fail?1:0);

View File

@@ -0,0 +1,48 @@
const B='http://127.0.0.1:3500';
const get=(u)=>fetch(B+u).then(r=>r.json());
const put=(u,b)=>fetch(B+u,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}).then(r=>r.json());
let pass=0,fail=0; const t=(n,ok)=>{ok?(pass++,console.log(' PASS',n)):(fail++,console.log(' FAIL',n))};
const kb0 = await get('/api/knowledge-base');
t('seeded KB loads', kb0.entries.length >= 630);
const f5 = kb0.entries.find(e=>e.label==='F5 BIG-IP');
t('F5 BIG-IP pre-mapped to Avi', f5 && f5.productIds.includes('ans-avi'));
t('seeded entry has no observations', f5.observations.length === 0);
// Save the seeded assessment -> should learn from its 10 candidates.
const a = await get('/api/assessments/northwind-financial');
await put('/api/assessments/northwind-financial', a);
const kb1 = await get('/api/knowledge-base');
const learned = kb1.entries.filter(e=>e.observations.length>0);
t('learned from all 10 candidates', learned.length === 10);
const splunk = kb1.entries.find(e=>e.label==='Splunk Enterprise');
t('Splunk Enterprise captured', !!splunk);
t('Splunk learned our real mapping', splunk.productIds.join()==='vcf-ops-logs,aod-dx-oi');
t('Splunk learned switchEase 2', splunk.switchEase === 2);
t('Splunk learned savingsPct 45', splunk.savingsPct === 45);
t('Splunk learned risks text', /SOC dashboards/.test(splunk.risks));
t('observation links back to assessment', splunk.observations[0].assessmentId === 'northwind-financial');
// Re-saving must update in place, not stack duplicates.
await put('/api/assessments/northwind-financial', a);
const kb2 = await get('/api/knowledge-base');
const splunk2 = kb2.entries.find(e=>e.label==='Splunk Enterprise');
t('re-save does not duplicate observations', splunk2.observations.length === 1);
t('entry count stable after re-save', kb2.entries.length === kb1.entries.length);
// A pinned entry must survive learning untouched.
const pinTarget = structuredClone(kb2);
const p = pinTarget.entries.find(e=>e.label==='Splunk Enterprise');
p.pinned = true; p.savingsPct = 99; p.switchEase = 5;
await put('/api/knowledge-base', pinTarget);
await put('/api/assessments/northwind-financial', a);
const kb3 = await get('/api/knowledge-base');
const splunk3 = kb3.entries.find(e=>e.label==='Splunk Enterprise');
t('curated entry keeps savingsPct', splunk3.savingsPct === 99);
t('curated entry keeps switchEase', splunk3.switchEase === 5);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail?1:0);

View File

@@ -0,0 +1,39 @@
const B='http://127.0.0.1:3500';
const get=(u)=>fetch(B+u).then(r=>r.json());
const put=(u,b)=>fetch(B+u,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}).then(r=>r.json());
const del=(u)=>fetch(B+u,{method:'DELETE'}).then(r=>r.json());
const post=(u,b)=>fetch(B+u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}).then(r=>r.json());
let pass=0,fail=0; const t=(n,ok)=>{ok?(pass++,console.log(' PASS',n)):(fail++,console.log(' FAIL',n))};
const entry=(kb,l)=>kb.entries.find(e=>e.label===l);
const cand=(o)=>({id:'c-x',incumbentVendor:'',incumbentProduct:'',domain:'',productIds:[],annualSpend:0,units:0,unitLabel:'',renewalDate:'',capability:4,switchEase:3,timeToRoiMonths:12,savingsPct:50,migrationCost:0,strategicValue:3,risks:'',notes:'',included:true,...o});
// Self-contained: create an assessment, learn from it, then take things away.
const a = await post('/api/assessments', { customerName: 'Prune Test Co' });
a.candidates = [
cand({ id:'c-1', incumbentVendor:'CyberArk', incumbentProduct:'PAM', domain:'iam', productIds:['esg-pam'], annualSpend:400000, savingsPct:55 }),
cand({ id:'c-2', incumbentVendor:'Okta', incumbentProduct:'Workforce Identity', domain:'iam', productIds:['esg-siteminder'], annualSpend:300000, savingsPct:40 }),
];
await put('/api/assessments/'+a.id, a);
const learned = await get('/api/knowledge-base');
t('both candidates learned', entry(learned,'CyberArk PAM').observations.some(o=>o.assessmentId===a.id)
&& entry(learned,'Okta Workforce Identity').observations.some(o=>o.assessmentId===a.id));
t('learned the real savings figure', entry(learned,'CyberArk PAM').savingsPct === 55);
// Remove one candidate -> only its observation is pruned.
a.candidates = a.candidates.filter(c => c.id !== 'c-1');
await put('/api/assessments/'+a.id, a);
const pruned = await get('/api/knowledge-base');
t('removed candidate is forgotten', !entry(pruned,'CyberArk PAM').observations.some(o=>o.assessmentId===a.id));
t('defaults revert to seeded values', entry(pruned,'CyberArk PAM').savingsPct === 50);
t('the entry survives for reuse', entry(pruned,'CyberArk PAM').productIds.includes('esg-pam'));
t('the other candidate is untouched', entry(pruned,'Okta Workforce Identity').observations.some(o=>o.assessmentId===a.id));
// Delete the assessment -> everything it taught is forgotten.
await del('/api/assessments/'+a.id);
const gone = await get('/api/knowledge-base');
t('deleting the assessment forgets the rest', !entry(gone,'Okta Workforce Identity').observations.some(o=>o.assessmentId===a.id));
t('unrelated learning is preserved', entry(gone,'Splunk Enterprise').observations.length === 1);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail?1:0);

View File

@@ -0,0 +1,79 @@
/**
* The account dossier holds internal pricing strategy, competitive risk and
* personal data. None of it may reach a customer deliverable.
*
* Self-contained: builds its own assessment, imports the sample dossier through
* the real endpoint, asserts on the redacted projection and the generated Word
* file, then removes itself.
*
* (The rendered report page is verified in a browser; its server-rendered shell
* carries no data, so asserting on that HTML would pass vacuously.)
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
const B = 'http://127.0.0.1:3500';
const json = (u, o) => fetch(B + u, o).then(r => r.json());
const send = (u, m, b) => json(u, { method: m, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) });
let pass = 0, fail = 0;
const t = (n, ok, x = '') => { ok ? (pass++, console.log(' PASS', n)) : (fail++, console.log(' FAIL', n, x)); };
const text = readFileSync(new URL('./fixtures/dossier-sample.txt', import.meta.url), 'utf8');
const SOURCE = 'https://docs.google.com/document/d/LEAKTEST/edit';
const a = await send('/api/assessments', 'POST', { customerName: 'Redaction Test Co' });
try {
const imported = await send('/api/assessments/x/intel', 'POST', { text, sourceUrl: SOURCE });
t('dossier parsed for the test', imported.proposals.length > 0 && !!imported.intel.company);
a.accountIntel = imported.intel;
a.candidates = imported.proposals.slice(0, 3).map((p, i) => ({
id: `c-${i}`, incumbentVendor: p.vendor, incumbentProduct: p.product, domain: p.domain,
productIds: p.productIds, annualSpend: 500000, units: 0, unitLabel: '', renewalDate: '',
capability: p.capability, switchEase: p.switchEase, timeToRoiMonths: p.timeToRoiMonths,
savingsPct: p.savingsPct, migrationCost: 50000, strategicValue: 3,
risks: '', notes: `From account dossier — ${p.evidence}`, included: true,
}));
await send(`/api/assessments/${a.id}`, 'PUT', a);
const full = await json(`/api/assessments/${a.id}`);
t('stored record keeps the dossier', !!full.accountIntel);
t('stored record keeps internal notes', full.candidates.every(c => c.notes));
const safe = await json(`/api/assessments/${a.id}/report`);
t('projection drops the dossier', safe.accountIntel === null);
t('projection drops internal notes', safe.candidates.every(c => c.notes === ''));
t('projection keeps every candidate', safe.candidates.length === full.candidates.length);
t('projection keeps the mappings', safe.candidates.every((c, i) => c.productIds.join() === full.candidates[i].productIds.join()));
t('projection keeps the spend figures', safe.candidates.every((c, i) => c.annualSpend === full.candidates[i].annualSpend));
// A .docx is a ZIP: the bytes must be inflated or a text search passes vacuously.
const buf = Buffer.from(await (await fetch(`${B}/api/assessments/${a.id}/docx`)).arrayBuffer());
const tmp = new URL('./.leak-test.docx', import.meta.url);
writeFileSync(tmp, buf);
const docx = execFileSync('unzip', ['-p', tmp.pathname, 'word/document.xml'], { maxBuffer: 32e6 }).toString('utf8');
t('Word file is a readable docx', docx.includes('<w:document') && docx.length > 2000);
const safeText = JSON.stringify(safe);
const secrets = [
SOURCE, 'docs.google.com', 'Tier 1 / Strategic', 'Renewal Risk', 'Intelligence Score',
'TechDirector', 'Shelfware', 'Tech Debt Signals', 'AM-SOUTHEAST', 'From account dossier',
'Everpure', 'Workload Distribution',
];
for (const s of secrets) {
const label = s.length > 30 ? s.slice(0, 30) + '…' : s;
t(`projection omits "${label}"`, !safeText.includes(s));
t(`Word omits "${label}"`, !docx.includes(s));
}
t('Word still names the customer', docx.includes('Redaction Test Co'));
t('Word still carries a mapped product', /Avi Load Balancer|VCF Networking|Layer7/.test(docx));
execFileSync('rm', ['-f', tmp.pathname]);
} finally {
await fetch(`${B}/api/assessments/${a.id}`, { method: 'DELETE' });
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);

45
scripts/test.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Runs the API test suites against a dev server on :3500.
# Each suite starts from a freshly seeded knowledge base so runs are repeatable.
set -uo pipefail
cd "$(dirname "$0")/.."
BASE=http://127.0.0.1:3500
curl -sf "$BASE/api/catalog" -o /dev/null || { echo "Start the dev server first: npm run dev"; exit 1; }
reseed() {
rm -f data/knowledge-base.json
node scripts/seed-kb.mjs > /dev/null
}
fail=0
echo "=== dossier parser ==="
./scripts/build-test-lib.sh
node scripts/test-dossier.mjs || fail=1
echo
echo "=== knowledge base: learning ==="
reseed
node scripts/test-kb-learning.mjs || fail=1
echo
echo "=== knowledge base: pruning ==="
reseed
curl -s "$BASE/api/assessments/northwind-financial" > /tmp/prt-nw.json
curl -s -X PUT -H "Content-Type: application/json" -d @/tmp/prt-nw.json \
"$BASE/api/assessments/northwind-financial" -o /dev/null
node scripts/test-kb-pruning.mjs || fail=1
echo
echo "=== customer-report redaction ==="
node scripts/test-report-leak.mjs || fail=1
# Leave the knowledge base in a clean, fully-learned state.
reseed
curl -s -X PUT -H "Content-Type: application/json" -d @/tmp/prt-nw.json \
"$BASE/api/assessments/northwind-financial" -o /dev/null
echo
[ $fail -eq 0 ] && echo "All suites passed." || echo "Some suites failed."
exit $fail