Files
SGF/scripts/reversals-http-proof.mjs
T
2026-11-02 08:06:45 +02:00

92 lines
13 KiB
JavaScript

import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
import {randomUUID,createHash} from 'node:crypto';
import {spawn} from 'node:child_process';
import vm from 'node:vm';
async function probe(mode,job,input={},legacy=false){
return new Promise((resolve,reject)=>{
const php=mode==='compras'?'C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe':'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe';
const p=spawn(php,[legacy?'scripts/payables-probe.php':'scripts/settlements-probe.php',mode,job],{windowsHide:true});let out='',err='';
p.stdout.on('data',d=>out+=d);p.stderr.on('data',d=>err+=d);p.on('error',reject);p.on('close',code=>{try{if(code)throw Error('Isolated probe failed '+job+': '+out+err);resolve(JSON.parse(out));}catch(e){reject(e);}});p.stdin.end(JSON.stringify(input));
});
}
const hosts=[];
for(const mode of ['compras','sgf']){
const base=`http://127.0.0.1:${mode==='compras'?8007:8008}`;
function client(){const cookies=new Map();let csrf='',context='';return {
async request(path,method='GET',body,key=randomUUID(),form=false){
const r=await fetch(base+path,{redirect:'manual',signal:AbortSignal.timeout(20000),method,headers:{Connection:'close',Accept:'application/json','Content-Type':form?'application/x-www-form-urlencoded':'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':csrf,'X-SGF-Context':context,'Idempotency-Key':key},...(body===undefined?{}:{body:form?new URLSearchParams(body):JSON.stringify(body)})});
for(const cookie of r.headers.getSetCookie()){const pair=cookie.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
const html=await r.text();let data;try{data=JSON.parse(html);}catch{}
csrf=html.match(/name="_token" value="([^"]+)"/)?.[1]??data?.csrf??csrf;
const embedded=html.match(/const settings=(.*);/);if(embedded){const s=JSON.parse(embedded[1]);csrf=s.csrf;context=s.context??context;new vm.Script(html.match(/<script>([\s\S]*?)<\/script>/)[1]);}
if(data?.context?.token)context=data.context.token;
return {status:r.status,data,html};
},
async login(role){assert.equal((await this.request('/demo?next=payments')).status,200);assert.equal((await this.request('/demo/entrar','POST',{role,next:'payments',_token:csrf},undefined,true)).status,302);if(mode==='sgf')await this.request('/api/session');await this.request('/supplier-payments');},
async list(){const r=await this.request('/api/supplier-payments');assert.equal(r.status,200);return r.data;},
async send(action,body,status=200,key=randomUUID()){const r=await this.request('/api/supplier-payments/'+action,'POST',body,key);assert.equal(r.status,status,mode+' '+action+' '+r.html.slice(0,200));return r.data;},
async logout(){await this.request('/demo');assert.equal((await this.request('/demo/sair','POST',{_token:csrf},undefined,true)).status,302);}
};}
const p=client(),v=client(),q=client();await p.login('preparacao');await v.login('validacao');await q.login('consulta');
let d=await q.list();const allocation=d.allocations[0],o=d.obligations[0],payment=d.payments[0];
assert.equal(o.balance_minor,760000);assert.equal(payment.state,'confirmed');assert.equal(payment.unallocated_minor,0);assert.equal(d.reversals_available,true);
const before=await probe(mode,'inspect');const original=JSON.stringify(allocation);
const body={allocation_id:Number(allocation.id),source_revision:allocation.source_revision,reason:'Reverter integralmente a afectação sintética para corrigir o montante'};
await p.send('reversal-prepare',{...body,reason:''},422);await p.send('reversal-prepare',{...body,amount:'1000.00'},422);
await p.send('reversal-prepare',{...body,source_revision:'outdated'},409);
await q.send('reversal-prepare',body,403);await v.send('reversal-prepare',body,403);
const race=await probe(mode,'race',{role:'preparacao',action:'reversal-prepare',body});assert.equal(race.waits,2);assert.deepEqual(race.results.map(r=>r.status),[200,200]);
const proposal=race.results[0].result;assert.equal(race.results[1].result.proposal_id,proposal.proposal_id);
const key=randomUUID();await p.send('reversal-prepare',body,200,key);assert.equal((await p.send('reversal-prepare',body,200,key)).replayed,true);
await p.send('reversal-prepare',{...body,reason:'Outro motivo incompatível'},409);await p.send('reversal-prepare',{...body,reason:'Outro motivo incompatível'},409,key);
d=await q.list();assert.equal(d.obligations[0].balance_minor,760000);assert.equal(d.payments[0].unallocated_minor,0);assert.equal(d.reversals.length,0);
const preparePage=await p.request('/supplier-payments');assert(preparePage.html.includes('Proposta de reversão #'));
const validatePage=await v.request('/supplier-payments');assert(validatePage.html.includes('data-action="reversal-confirm"'));
const decision={proposal_id:proposal.proposal_id,reason:'Confirmação independente da reversão integral documentada'};
await q.send('reversal-confirm',decision,403);await p.send('reversal-confirm',decision,403);
await probe(mode,'grant',{role:'preparacao',action:'reversal_confirm',enabled:true});await p.send('reversal-confirm',decision,409);await probe(mode,'grant',{role:'preparacao',action:'reversal_confirm',enabled:false});
await probe(mode,'person',{same:true});await v.send('reversal-confirm',decision,409);await probe(mode,'person',{same:false});
await probe(mode,'source',{override:{total_minor:1160001}},true);await v.send('reversal-confirm',decision,409);assert.equal((await q.list()).obligations[0].balance_minor,null);await probe(mode,'source',{},true);
assert.equal((await probe(mode,'foreign',{action:'reversal-confirm',body:decision})).refused,true);
const revoked=await probe(mode,'race',{role:'validacao',action:'reversal-confirm',body:decision,revoke:'reversal_confirm'});assert.equal(revoked.waits,2);assert.deepEqual(revoked.results.map(r=>r.status),[403,403]);await probe(mode,'grant',{role:'validacao',action:'reversal_confirm',enabled:true});
const counts=await probe(mode,'inspect'),failedKey=randomUUID();await probe(mode,'fault',{enabled:true,kind:'supplier_allocation.reversed'});
await v.send('reversal-confirm',decision,500,failedKey);await probe(mode,'fault',{enabled:false});assert.deepEqual((await probe(mode,'inspect')).counts,counts.counts);
assert.equal((await q.list()).obligations[0].balance_minor,760000);
const reversal=await v.send('reversal-confirm',decision,200,failedKey);assert.equal((await v.send('reversal-confirm',decision,200,failedKey)).replayed,true);
const confirmedRace=await probe(mode,'race',{role:'validacao',action:'reversal-confirm',body:decision});assert.equal(confirmedRace.waits,2);assert.deepEqual(confirmedRace.results.map(r=>r.status),[200,200]);assert(confirmedRace.results.every(r=>r.result.reversal_id===reversal.reversal_id));
await v.send('reversal-confirm',{...decision,reason:'Tentativa de segunda reversão incompatível'},409);
assert.equal((await p.send('reversal-prepare',body)).proposal_id,proposal.proposal_id);
d=await q.list();assert.equal(d.reversals.length,1);assert.equal(d.reversals[0].amount_minor,400000);assert.equal(d.obligations[0].balance_minor,1160000);assert.equal(d.payments[0].unallocated_minor,400000);assert.equal(d.payments[0].state,'confirmed');assert.equal(JSON.stringify(d.allocations[0]),original);
const stages=[{stage:'before_reversal',debt_minor:760000,unallocated_minor:0},{stage:'full_reversal',debt_minor:1160000,unallocated_minor:400000}];
const oldProposal=d.allocation_proposals[0];
await v.send('allocation-decide',{proposal_id:Number(oldProposal.id),decision:'confirmed',reason:oldProposal.decision.reason});
assert.equal((await q.list()).obligations[0].balance_minor,1160000,'Repeating a reversed original must not reapply it');
const plan={payment_id:Number(payment.id),reference:'SIM-CORRECT-3000',allocations:[{obligation_id:Number(o.id),source_revision:o.source_revision,amount:'3000.00'}],reason:'Nova afectação corrigida depois de reversão integral'};
await p.send('allocation-prepare',{...plan,reference:'SIM-CORRECT-EXCESS',allocations:[{...plan.allocations[0],amount:'4000.01'}]},409);
await p.send('allocation-prepare',{...plan,reference:'SIM-CORRECT-STALE',allocations:[{...plan.allocations[0],source_revision:'outdated'}]},409);
// A new reference can propose the same old plan after compensation; old references still recover the original.
const samePlan=await p.send('allocation-prepare',{...plan,reference:'SIM-CORRECT-SAME-4000',allocations:[{...plan.allocations[0],amount:'4000.00'}]});
assert.notEqual(samePlan.proposal_id,Number(d.allocation_proposals[0].id));
const oldRef=await p.send('allocation-prepare',{...plan,reference:'SIM-INTERFACE-ALLOC-001',allocations:[{...plan.allocations[0],amount:'4000.00'}]});assert.equal(oldRef.proposal_id,Number(d.allocation_proposals[0].id));
const correction=await p.send('allocation-prepare',plan);assert.equal((await q.list()).obligations[0].balance_minor,1160000);
const correctionDecision={proposal_id:correction.proposal_id,decision:'confirmed',reason:'Confirmação independente da nova afectação de 3 000 MZN'};
const correctionRace=await probe(mode,'race',{role:'validacao',action:'allocation-decide',body:correctionDecision});assert.equal(correctionRace.waits,2);assert.deepEqual(correctionRace.results.map(r=>r.status),[200,200]);assert.equal(correctionRace.results[0].result.decision_id,correctionRace.results[1].result.decision_id);
await v.send('allocation-decide',{...correctionDecision,proposal_id:samePlan.proposal_id},409);
assert.equal((await p.send('allocation-prepare',plan)).proposal_id,correction.proposal_id);await v.send('allocation-decide',correctionDecision);
d=await q.list();assert.equal(d.obligations[0].balance_minor,860000);assert.equal(d.payments[0].unallocated_minor,100000);assert.equal(d.obligations[0].principal_minor,1160000);assert.equal(d.obligations[0].due_on,null);assert.equal(d.obligations[0].days_overdue,null);assert.equal(d.allocations.length,2);assert.equal(d.reversals.length,1);assert.equal(d.payments.length,1);
assert.equal(JSON.stringify(d.allocations[0]),original);assert.equal((await probe(mode,'inspect')).original_hash,before.original_hash);
const reversedEvents=d.events.filter(e=>e.kind==='supplier_allocation.reversed');assert.equal(reversedEvents.length,1);assert.notEqual(String(reversedEvents[0].actor_id),String(d.reversal_proposals[0].prepared_by));assert.notEqual(reversedEvents[0].person,d.reversal_proposals[0].prepared_person);
const events=d.events.map(e=>e.id);await q.logout();await q.login('consulta');d=await q.list();assert.deepEqual(d.events.map(e=>e.id),events);assert.equal(d.obligations[0].balance_minor,860000);
const page=await q.request('/supplier-payments');assert(page.html.includes('8 600.00 MZN'));assert(page.html.includes('1 000.00 MZN'));assert(page.html.includes('Revertida integralmente'));assert(page.html.includes('Atraso indeterminado'));assert(!page.html.includes('data-action='));
const obligations=await q.request(mode==='compras'?'/obrigacoes-fornecedores':'/supplier-payables');assert(obligations.html.includes('8 600.00 MZN'));
const integrity=await probe(mode,'reversal-immutable');assert.equal(integrity.immutable_operations_refused,4);assert.equal(integrity.invalid_compensations_refused,3);
const final=await probe(mode,'inspect');assert.equal(final.counts.ps_events,final.counts.ps_outbox);assert.equal(final.counts.ps_reversals,1);assert.equal(final.counts.ps_reversal_proposals,1);
stages.push({stage:'corrected_allocation',debt_minor:860000,unallocated_minor:100000});
hosts.push({host:mode,passed:true,stages,due_on:null,days_overdue:null,originals_preserved:true,full_reversals:1,payments:1,allocations:2,events:events.length,outbox:final.counts.ps_outbox,integrity,races:[race,revoked,confirmedRace,correctionRace],checks:['proposal_has_no_effect','new_key_retry_recovers','incompatible_repeat_conflicts','partial_payload_refused','single_full_compensation','payment_remains_confirmed','distinct_actor_and_person','permission_revocation_after_wait','cross_company_refused','source_version_conflict','rollback_before_event_and_same_key_recovery','balance_limits','same_plan_new_cycle_old_reference_recovery','original_confirmation_cannot_reapply_reversed_effect','canonical_host_balance','history_reentry','read_only_role_without_forms'],visual_review:false});
console.log(mode+': reversal/correction passed: 7600 -> 11600 -> 8600; unallocated 0 -> 4000 -> 1000.');
}
const hash=createHash('sha256').update(await fs.readFile('packages/payables/src/SupplierSettlements.php')).digest('hex');
await fs.writeFile('docs/proofs/supplier-reversals-20261030.json',JSON.stringify({version:'COMMON-ALLOCATION-FULL-REVERSAL-SIM-v1',checked_at:new Date().toISOString(),core_sha256:hash,hosts,public_enabled:false,bank_execution:false,accounting:false,persistent_demo_upgraded:false,visual_review:false},null,2)+'\n');