/* Reproduce tests of pinned actual portfolio modules, using fictional records. * node backup-test.cjs.txt . * Standard Node libraries only. No DOM, browser storage, network or real wallet. */ 'use strict'; const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),C=require('node:crypto'); const folder=path.resolve(process.argv[2]||'.'),context=vm.createContext({crypto:C.webcrypto,TextEncoder,TextDecoder,btoa,atob}),cache=new Map(); const manifest=JSON.parse(fs.readFileSync(path.join(folder,'source-manifest.json'),'utf8')); const checks=[],cases=[]; const clone=x=>JSON.parse(JSON.stringify(x)); function check(name,condition,details=''){checks.push({name,passed:!!condition,details});if(!condition)throw Error(name+': '+details);} const eq=(a,b)=>JSON.stringify(a)===JSON.stringify(b); function load(name){ if(cache.has(name))return cache.get(name).exports; const rel='source/'+name+'.txt',item=manifest.items.find(x=>x.snapshot===rel); if(!item)throw Error('Unpinned source: '+name); const source=fs.readFileSync(path.join(folder,rel));check('Pinned source hash '+name,C.createHash('sha256').update(source).digest('hex')===item.sha256); const module={exports:{}};cache.set(name,module); const factory=new vm.Script('(function(exports,require,module){\n'+source.toString('utf8')+'\n})',{filename:rel}).runInContext(context); factory(module.exports,id=>{if(!/^\.\/[a-z0-9-]+\.js$/.test(id))throw Error('Unsupported dependency '+id);return load(id.slice(2));},module); return module.exports; } function write(name,data){fs.writeFileSync(path.join(folder,name),JSON.stringify(data,null,2)+'\n');} function csv(rows){const keys=Object.keys(rows[0]),cell=x=>'"'+String(x??'').replaceAll('"','""')+'"';return [keys,...rows.map(r=>keys.map(k=>r[k]))].map(r=>r.map(cell).join(',')).join('\r\n')+'\r\n';} function describe(id,label,state,added=null,duplicates=null,note=''){const r=L.ledger(state,'USD',110);cases.push({id,label,transactions:state.transactions.length,accounts:state.accounts.length,balance_checks:state.reconciliations.length,added,duplicates,total_ltc:r.amount,main_ltc:r.accounts.find(x=>x.id==='main').amount,cold_ltc:r.accounts.find(x=>x.id==='cold').amount,note});return r;} function units(decimal,scale=8){const text=String(decimal),parts=text.split('.');if(parts.length>2||!/^[0-9]+$/.test(parts[0])||parts[1]&&!/^[0-9]+$/.test(parts[1])||(parts[1]||'').length>scale)throw Error('Invalid exact fixture decimal');return BigInt(parts[0])*10n**BigInt(scale)+BigInt((parts[1]||'').padEnd(scale,'0')||0);} function amount(value,scale=8){const text=value.toString().padStart(scale+1,'0');return text.slice(0,-scale)+'.'+text.slice(-scale);} function close(name,actual,expected){check(name,Math.abs(actual-Number(expected))<1e-9,'expected '+expected+', module '+actual);} async function rejects(name,fn,pattern){try{await fn();throw Error('Expected rejection not observed');}catch(e){check(name,pattern.test(e.message),e.message);return e.message;}} const L=load('portfolio-ledger.js'),IO=load('portfolio-io.js'),E=load('portfolio-crypto.js'); (async()=>{ const started=new Date().toISOString(),input=JSON.parse(fs.readFileSync(path.join(folder,'fixture-input.json'),'utf8')),state=L.normalize(input); const empty=L.normalize({version:4,accounts:[{id:'main',name:'Main account'}],transactions:[],reconciliations:[]}); const before=JSON.stringify(state),base=describe('C01','Original fictional ledger',state),reconciliation=L.reconciliation(state); check('Two distinct identical purchase records retained',state.transactions.filter(t=>t.type==='buy').length===2); const expectedUnits=units('0.75')+units('0.75')-units('0.0001')-units('0.2'); const expectedMain=units('0.75')+units('0.75')-units('0.4')-units('0.0001')-units('0.2'),expectedCold=units('0.4'); check('Independent integer quantity sum',expectedUnits===129990000n); check('Independent account quantities add to total',expectedMain+expectedCold===expectedUnits); close('Module total agrees with integer litoshis',base.amount,amount(expectedUnits)); close('Module source account agrees with integer litoshis',base.accounts.find(a=>a.id==='main').amount,amount(expectedMain)); close('Module destination account agrees with integer litoshis',base.accounts.find(a=>a.id==='cold').amount,amount(expectedCold)); const usdScale=100000000n,totalBuy=units('72.60')*2n,totalBought=units('1.5'),networkCost=totalBuy*units('0.0001')/totalBought; const qtyAfterFee=totalBought-units('0.0001'),basisAfterFee=totalBuy-networkCost,saleBasis=basisAfterFee*units('0.2')/qtyAfterFee; const remainingBasis=basisAfterFee-saleBasis,netSale=units('23.75'),endingValue=expectedUnits*110n,profit=endingValue+netSale-totalBuy; close('Independent cash purchase costs',base.purchases,amount(totalBuy)); close('Independent pooled network cost',base.networkBasis,amount(networkCost)); close('Independent partial-sale released basis',base.history.find(t=>t.type==='sell').costBasis,amount(saleBasis)); close('Independent remaining basis',base.basis,amount(remainingBasis)); close('Independent net sale proceeds',base.proceeds,amount(netSale)); close('Independent ending value',base.value,amount(endingValue)); close('Independent total economic result',base.total,amount(profit)); close('Main saved check difference',reconciliation.find(x=>x.accountId==='main').difference,'0.00010000'); close('Wallet saved check difference',reconciliation.find(x=>x.accountId==='cold').difference,'0.00000000'); const json=IO.backup(state),parsed=IO.parse(json);write('fixture-backup.json',JSON.parse(json)); check('JSON export is version 4',JSON.parse(json).version===4); check('JSON parse restores complete normalized state',eq(parsed,state)); describe('C02','JSON parsed',parsed,null,null,'All three accounts and both balance checks preserved.'); const restored=L.merge(empty,parsed); check('Fresh JSON restore complete equality',eq(restored.state,state)); check('Fresh import adds all four records',restored.added.length===4&&restored.duplicates===0); describe('C03','JSON merged into empty portfolio',restored.state,4,0,'Default Main account renamed by the fresh import special case.'); const repeated=L.merge(restored.state,parsed); check('Repeat import adds zero and skips four',repeated.added.length===0&&repeated.duplicates===4); check('Repeat import leaves complete state unchanged',eq(repeated.state,state)); describe('C04','Same JSON imported again',repeated.state,0,4); const partial=L.normalize({...state,transactions:[state.transactions[0]],reconciliations:[]}),completed=L.merge(partial,parsed); check('Partial import adds three and skips one',completed.added.length===3&&completed.duplicates===1); check('Partial import preserves two separate fills',completed.state.transactions.filter(t=>t.type==='buy').length===2); check('Partial result equals full normalized snapshot',eq(completed.state,state)); describe('C05','JSON merged with one existing purchase',completed.state,3,1); const conflict=clone(parsed);conflict.transactions[0].amount=0.76; const conflictError=await rejects('Changed quantity with same ID rejected',()=>L.merge(state,conflict),/identifier conflicts/); check('Rejected import did not mutate input',JSON.stringify(state)===before); cases.push({id:'C06',label:'Same ID with changed quantity',outcome:'rejected',message:conflictError}); const named=clone(state);named.accounts[0].name='Earlier account name';named.reconciliations=[{accountId:'main',date:'2026-09-29',amount:1.0999}]; const retained=L.merge(named,parsed); check('Existing account name retained on merge',retained.state.accounts[0].name==='Earlier account name'); check('Existing balance check retained despite newer imported date',retained.state.reconciliations.find(x=>x.accountId==='main').date==='2026-09-29'); check('Missing second-account balance check added',retained.state.reconciliations.some(x=>x.accountId==='cold')); describe('C07','Merge keeps existing account name and check',retained.state,0,4,'Existing main check 2026-09-29 retained; imported check 2026-09-30 did not replace it.'); const metadata=clone(parsed);metadata.transactions[0].fx.EUR=0.95;metadata.transactions[0].valuationPrice=97; const metadataMerge=L.merge(state,metadata); check('FX-only and valuation-only differences do not replace existing transaction',metadataMerge.state.transactions[0].fx.EUR===0.92&&metadataMerge.state.transactions[0].valuationPrice===96); check('Metadata-only changed record counted as duplicate',metadataMerge.duplicates===4&&metadataMerge.added.length===0); describe('C08','Merge retains existing FX and valuation',metadataMerge.state,0,4,'Imported first-buy EUR/USD input 0.95 and valuation 97 were not applied.'); const exportedCsv=IO.csv(state);fs.writeFileSync(path.join(folder,'fixture-transactions.csv'),exportedCsv); const csvParsed=IO.parse(exportedCsv),csvResult=describe('C09','CSV parsed',csvParsed,null,null,'Only two used accounts reconstructed; unused account and saved balance checks absent.'); check('CSV restores all transaction fields',eq(csvParsed.transactions,state.transactions)); check('CSV restores only two used account names',csvParsed.accounts.length===2&&csvParsed.accounts[0].name==='Exchange records'&&csvParsed.accounts[1].name==='Offline wallet'); check('CSV has no saved balance checks',csvParsed.reconciliations.length===0); check('CSV omits unused account',!csvParsed.accounts.some(a=>a.id==='unused')); close('CSV keeps same total LTC',csvResult.amount,amount(expectedUnits)); const csvRestored=L.merge(empty,csvParsed);describe('C10','CSV merged into empty portfolio',csvRestored.state,csvRestored.added.length,csvRestored.duplicates); check('CSV fresh import adds four',csvRestored.added.length===4&&csvRestored.duplicates===0); const csvRepeated=L.merge(state,csvParsed); check('CSV repeated onto JSON state skips four',csvRepeated.added.length===0&&csvRepeated.duplicates===4); check('CSV merge does not erase existing unused account/checks',eq(csvRepeated.state,state)); const unusual=clone(state);unusual.transactions[3].notes='=fictional()\n"quoted", comma'; const unusualCsv=IO.csv(unusual),unusualParsed=IO.parse(unusualCsv); check('CSV protects tested formula-leading note',unusualCsv.includes("'=fictional()")); check('CSV quoted multiline note round-trips',unusualParsed.transactions[3].notes===unusual.transactions[3].notes); const testPassword='fictional-backup-test-only'; const encrypted=await E.encrypt(json,testPassword),envelope=JSON.parse(encrypted);write('fixture-encrypted.json',envelope); check('Encryption envelope version',envelope.format==='litecoin.watch.portfolio.encrypted'&&envelope.version===1); check('Encryption algorithms and work factor',envelope.cipher==='AES-256-GCM'&&envelope.kdf==='PBKDF2-SHA256'&&envelope.iterations===600000); check('Salt and nonce lengths',Buffer.from(envelope.salt,'base64').length===16&&Buffer.from(envelope.iv,'base64').length===12); const decrypted=await E.decrypt(encrypted,testPassword); check('Decrypted plaintext exactly equals original export',decrypted===json); const encryptedRestore=L.merge(empty,IO.parse(decrypted)); check('Encrypted restore full-state equality',eq(encryptedRestore.state,state)); describe('C11','Encrypted JSON decrypted and restored',encryptedRestore.state,encryptedRestore.added.length,encryptedRestore.duplicates); const wrong=await rejects('Wrong test password rejected',()=>E.decrypt(encrypted,'incorrect-test-password'),/wrong password or a damaged backup/); cases.push({id:'C12',label:'Wrong backup password',outcome:'rejected',message:wrong}); const tampered=clone(envelope),bytes=Buffer.from(tampered.data,'base64');bytes[0]^=1;tampered.data=bytes.toString('base64'); const damaged=await rejects('One changed ciphertext byte rejected',()=>E.decrypt(JSON.stringify(tampered),testPassword),/wrong password or a damaged backup/); cases.push({id:'C13',label:'One ciphertext byte changed',outcome:'rejected',message:damaged}); const changedNonce=clone(envelope),nonce=Buffer.from(changedNonce.iv,'base64');nonce[0]^=1;changedNonce.iv=nonce.toString('base64'); await rejects('Changed nonce rejected',()=>E.decrypt(JSON.stringify(changedNonce),testPassword),/wrong password or a damaged backup/); await rejects('Short password rejected',()=>E.encrypt(json,'too-short'),/12–1,024/); const second=JSON.parse(await E.encrypt(json,testPassword)); check('Second encryption uses different salt and nonce',second.salt!==envelope.salt&&second.iv!==envelope.iv); check('Second encrypted file differs',second.data!==envelope.data); const changedKdf=clone(envelope);changedKdf.iterations=600001; await rejects('Unsupported envelope work factor rejected',()=>E.decrypt(JSON.stringify(changedKdf),testPassword),/Unsupported encrypted backup version/); check('All tests leave original fixture unchanged',JSON.stringify(state)===before); write('fixture-normalized.json',state);write('fixture-restored.json',restored.state);write('fixture-csv-restored.json',csvRestored.state); const summary={total_ltc:amount(expectedUnits),main_ltc:amount(expectedMain),cold_ltc:amount(expectedCold),purchase_cost_usd:amount(totalBuy),net_sale_usd:amount(netSale),network_basis_usd:amount(networkCost),remaining_basis_usd:amount(remainingBasis),ending_price_usd:'110',ending_value_usd:amount(endingValue),total_economic_result_usd:amount(profit)}; const result={version:'1.0.0',measured_at_utc:started,completed_at_utc:new Date().toISOString(),node_version:process.version, scope:'Pinned actual module functions exercised in Node/WebCrypto with fictional records; no browser UI, localStorage, real wallet, exchange account, cloud or recovery-secret test.', source_manifest_sha256:C.createHash('sha256').update(fs.readFileSync(path.join(folder,'source-manifest.json'))).digest('hex'), fixture_original:state,summary,module_summary:clone(base),reconciliation:clone(reconciliation),cases,assertions_passed:checks.length,assertions:checks, encrypted_fixture_notice:'Only fictional public data. Test password is fictional-backup-test-only and must never be reused for a real backup.'}; write('results.json',result);fs.writeFileSync(path.join(folder,'cases.csv'),csv(cases.map(r=>({id:r.id,label:r.label,transactions:r.transactions??'',accounts:r.accounts??'',balance_checks:r.balance_checks??'',added:r.added??'',duplicates:r.duplicates??'',total_ltc:r.total_ltc??'',outcome:r.outcome??'completed',note:r.note??r.message??''})))); console.log(JSON.stringify({assertions_passed:checks.length,cases:cases.length,summary,scope:result.scope})); })().catch(error=>{write('failed-checks.json',checks);console.error(error);process.exitCode=1;});