Files
SGF/scripts/ledger-cost-correction-http.mjs
T
2026-11-04 09:37:20 +02:00

22 lines
10 KiB
JavaScript

import fs from 'node:fs';import path from 'node:path';import net from 'node:net';import assert from 'node:assert/strict';import crypto from 'node:crypto';import {spawn} from 'node:child_process';
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned correction fixture required');const root=path.join(state.root,'private/ledger-'+tag),g=read(path.join(root,'cost-fixture.json')),cg=read(path.join(root,'cost-correction-fixture.json')),out=path.join(root,'cost-correction-http.json');if(fs.existsSync(out))throw Error('Completed correction HTTP proof preserved');
await new Promise((resolve,reject)=>{const s=net.createServer();s.once('error',reject);s.listen(8047,'127.0.0.1',()=>s.close(resolve));});const log=fs.openSync(path.join(root,'cost-correction-http-server.log'),'a'),server=spawn('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-S','127.0.0.1:8047',path.resolve('scripts/ledger-cost-http-router.php')],{windowsHide:true,stdio:['ignore',log,log],env:{...process.env,LEDGER_QA_SETTINGS:path.join(root,'settings.json')}});
let checks=0;const ok=(v,m)=>{checks++;assert(v,m);};const client=()=>{const jar=new Map();let auth;const request=async(url,body,key=crypto.randomUUID(),extra={})=>{const r=await fetch('http://127.0.0.1:8047'+url,{method:body?'POST':'GET',headers:{Accept:'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-SGF-Context':auth?.context??'',...(body?{'Content-Type':'application/json','X-CSRF-TOKEN':auth?.csrf??'','Idempotency-Key':key}:{}),...extra},body:body?JSON.stringify(body):undefined,redirect:'manual',signal:AbortSignal.timeout(15000)});for(const c of r.headers.getSetCookie()){const p=c.split(';')[0],i=p.indexOf('=');jar.set(p.slice(0,i),p.slice(i+1));}const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return{status:r.status,data,text,headers:r.headers};};return{request,enter:async role=>{const r=await request('/qa/cost-enter/'+role);ok(r.status===200,'Owned '+role+' entry');auth=r.data;ok(jar.has('sgf_cost_'+tag)&&!jar.has('XSRF-TOKEN'),'Separate session cookie and shared CSRF preserved');},module:async module=>{const old=auth.context,r=await request('/api/context',{...g.scope,module_id:module});ok(r.status===200,'Explicit module transition');auth={csrf:r.data.csrf,context:r.data.context.token};return old;}};};
const api='/api/general-ledger/costs?order_id='+g.order_id,html='/general-ledger/costs?order_id='+g.order_id;const command=async(c,a,b,key)=>{const r=await c.request('/api/general-ledger/'+a,b,key);ok(r.status===200,a+' '+r.status+' '+(r.data?.message??''));return r.data;};
try{
const p=client(),v=client(),r=client(),tech=client();let ready=false;for(let n=0;n<50&&!ready;n++){try{ready=(await r.request(api)).status===401;}catch{}if(!ready)await new Promise(res=>setTimeout(res,100));}ok(ready,'Unauthenticated correction read refused');await p.enter('prepare');await v.enter('validate');await r.enter('read');await tech.enter('technician');ok((await tech.request(api)).status===403,'Technical rights do not grant accounting');
let w=await r.request(api,undefined,undefined,{'X-Cost-QA-Proof':'read'});ok(w.status===200&&w.data.cost.source.principal_minor===29500&&w.data.cost.original_journal.debit_minor===27500&&w.data.cost.accounting_state==='requires_correction'&&w.data.corrections.length===0&&w.data.book.journals.length===1,'Actual original275/current295 requires explicit correction');const initialSource=JSON.stringify(w.data.cost.facts);const mode=read(path.join(root,'cost-http-read.json'));ok(mode.transaction.isolation==='repeatable read'&&mode.transaction.read_only==='on'&&mode.writer_locks===0,'Source, group and book reads coherent without writer locks');
let view=await p.request(html);ok(view.status===200&&view.text.includes('data-action="correct-prepare"')&&view.text.includes('20,00 MZN')&&!view.text.includes('data-action="effect-prepare"'),'Individual pair capture with explicit difference20');fs.writeFileSync(path.join(root,'correction-preparer.html'),view.text);view=await r.request(html);ok(view.status===200&&!view.text.includes('data-action='),'Reader cannot decide');
const body={journal_id:cg.original_journal_id,journal_on:'2026-10-29',period_key:'QA-2026-10',reason:'Explicit original275/current295 pair, preserving original source evidence.'};ok((await p.request('/api/general-ledger/correct-prepare',body,undefined,{'X-CSRF-TOKEN':''})).status===419,'Real CSRF');ok((await r.request('/api/general-ledger/correct-prepare',body)).status===403,'No implicit correction from view');ok((await p.request('/api/general-ledger/correct-prepare',{...body,journal_id:999999})).status===409,'Foreign original refused');ok((await p.request('/api/general-ledger/period-prepare',{period_key:'QA-2026-10',reason:'Unresolved changed cost blocks closing.'})).status===409,'Changed cost cannot close before correction');
for(const q of ['order_id=999999','order_id=0','order_id%5Bx%5D=1','order_id='+g.order_id+'&tenant_id=9'])ok([403,422].includes((await r.request('/api/general-ledger/costs?'+q)).status),'Invalid/foreign source query refused');
const old=await p.module('finance');ok((await p.request(api)).status===409,'Wrong module refuses correction workspace');await p.module('accounting');ok((await p.request('/api/general-ledger/correct-prepare',body,undefined,{'X-SGF-Context':old})).status===409,'Stale generation refuses correction');
const prepareKey=crypto.randomUUID(),proposal=await command(p,'correct-prepare',body,prepareKey);ok((await command(p,'correct-prepare',body,prepareKey)).replayed,'Capture retry receipt');w=await r.request(api);const group=w.data.corrections[0],part=w.data.book.proposals.find(x=>x.id===group.reverse_proposal_id);ok(JSON.parse(group.reverse_snapshot).impact.debit_minor===27500&&JSON.parse(group.replacement_snapshot).impact.debit_minor===29500,'Group has exact asymmetric impacts');view=await v.request(html);ok(view.status===200&&view.text.includes('data-action="correct-validate"')&&!view.text.includes('data-action="validate"'),'Reviewer sees only joint decision, no individual part');fs.writeFileSync(path.join(root,'correction-reviewer.html'),view.text);view=await v.request('/general-ledger');ok(view.status===200&&!view.text.includes('data-action="validate"'),'Generic diary also hides isolated part decision');
const review={correction_id:proposal.correction_id,revision:proposal.revision,decision:'approved',reason:'Independent conference of four lines, original275/latest295/difference20.'};ok((await p.request('/api/general-ledger/correct-validate',review)).status===403,'Preparer has no independent correction review');ok((await v.request('/api/general-ledger/correct-validate',{...review,revision:'0'.repeat(64)})).status===409,'Old group revision refused');ok((await v.request('/api/general-ledger/validate',{proposal_id:part.id,revision:part.revision,decision:'approved',reason:'Isolated part review forbidden.'})).status===409,'Public individual review refused');
const post={correction_id:proposal.correction_id,revision:proposal.revision,reason:'Apply reviewed four-line cost pair atomically once.'};ok((await p.request('/api/general-ledger/correct-post',post)).status===409,'Pair cannot apply before distinct approval');await command(v,'correct-validate',review);view=await p.request(html);ok(view.status===200&&view.text.includes('data-action="correct-post"')&&!view.text.includes('data-action="post"'),'Only joint application exposed');fs.writeFileSync(path.join(root,'correction-post.html'),view.text);ok((await p.request('/api/general-ledger/post',{proposal_id:part.id,revision:part.revision,reason:'Isolated balanced reversal still forbidden.'})).status===409,'Public individual application refused');
const key=crypto.randomUUID(),result=await command(p,'correct-post',post,key);ok((await command(p,'correct-post',post,key)).replayed,'Pair retry receipt');ok((await command(p,'correct-post',post)).result_id===result.result_id,'Other key recovers same pair');ok((await p.request('/api/general-ledger/correct-prepare',body)).status===409,'No second correction chain');w=await r.request(api);ok(w.data.book.journals.length===3&&w.data.book.lines.length===6&&w.data.correction_results.length===1&&w.data.cost.accounting_state==='current'&&JSON.stringify(w.data.cost.facts)===initialSource,'Three journals/six lines resolve current cost without mutating source');ok(w.data.book.events.length===9,'All proposal/review/post history retained');
const dates='from_on=2026-10-01&to_on=2026-10-31';let report=await r.request('/api/general-ledger/trial-balance?'+dates);ok(report.status===200&&report.data.totals.debit_minor===84500&&report.data.totals.credit_minor===84500,'Gross trial balance845 each side');report=await r.request('/api/general-ledger/account-ledger?'+dates+'&account_id='+g.accounts['QA-CUSTO-DIRECTO']);ok(report.status===200&&report.data.closing_net_minor===29500&&report.data.rows.length===3,'Account ledger exact295 after reversal and replacement');view=await r.request('/general-ledger/account-ledger?'+dates+'&account_id='+g.accounts['QA-CUSTO-DIRECTO']);ok(view.status===200&&view.text.includes('/general-ledger/costs?order_id='+g.order_id),'Report traces corrected cost source');
const period=await command(p,'period-prepare',{period_key:'QA-2026-10',reason:'Capture explicit correction resolution and current source.'});await command(v,'period-validate',{check_id:period.check_id,revision:period.revision,decision:'approved',reason:'Independent corrected source/book period conference.'});const close={check_id:period.check_id,revision:period.revision,reason:'Close corrected cost QA period without reopening.'},closeKey=crypto.randomUUID(),closed=await command(p,'period-close',close,closeKey);ok((await command(p,'period-close',close,closeKey)).replayed,'Close retry receipt');ok((await command(p,'period-close',close)).closure_id===closed.closure_id,'Other key single close');view=await p.request(html);ok(view.status===200&&!view.text.includes('data-action='),'Closed view exposes no commands');
fs.writeFileSync(out,JSON.stringify({passed:true,checks,actual_http:true,csrf_and_context:true,indivisible_four_line_impact:true,isolated_public_parts_refused:true,original_minor:27500,replacement_minor:29500,delta_minor:2000,journals:3,lines:6,applied_groups:1,gross_minor:84500,balance_minor:29500,source_snapshot_unchanged:true,one_reviewed_close:true,read_only_workspace:true,writer_locks:0,visual_review:false},null,2)+'\n');console.log(JSON.stringify({passed:true,checks,journals:3,lines:6}));
}finally{server.kill();fs.closeSync(log);}