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:
12
scripts/build-test-lib.sh
Executable file
12
scripts/build-test-lib.sh
Executable 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
|
||||
112
scripts/fixtures/dossier-sample.txt
Normal file
112
scripts/fixtures/dossier-sample.txt
Normal 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
94
scripts/seed-kb.mjs
Normal 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
27
scripts/test-client.mjs
Normal 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
64
scripts/test-dossier.mjs
Normal 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);
|
||||
48
scripts/test-kb-learning.mjs
Normal file
48
scripts/test-kb-learning.mjs
Normal 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);
|
||||
39
scripts/test-kb-pruning.mjs
Normal file
39
scripts/test-kb-pruning.mjs
Normal 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);
|
||||
79
scripts/test-report-leak.mjs
Normal file
79
scripts/test-report-leak.mjs
Normal 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
45
scripts/test.sh
Executable 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
|
||||
Reference in New Issue
Block a user