Files
SGF/scripts/settlements-interface-check.mjs
2026-11-02 08:06:45 +02:00

74 lines
11 KiB
JavaScript

import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
import {randomUUID,createHash} from 'node:crypto';
import vm from 'node:vm';
const hosts=[];
async function checkFormFeedback(html) {
const form={dataset:{action:'payment-prepare'},querySelector:()=>null,querySelectorAll:()=>[]};
const error={textContent:'',focused:false,focus(){this.focused=true;}},status={textContent:''},reload={},location={hash:'',reloads:0,reload(){this.reloads++;}};
let fail=true;const keys=[];
const sandbox={document:{querySelector:s=>({'#error':error,'#status':status,'#reload':reload}[s]),querySelectorAll:s=>s==='[data-action]'?[form]:[]},FormData:class extends Map{constructor(){super([['obligation_id','1'],['amount','4000.00']]);}},crypto:{randomUUID},location,fetch:async(url,options)=>{keys.push(options.headers['Idempotency-Key']);return {ok:!fail,json:async()=>fail?{message:'Dados inválidos',errors:{amount:['Use um montante sustentado.']}}:{payment_id:1}};}};
vm.runInNewContext(html.match(/<script>([\s\S]*?)<\/script>/)[1],sandbox);
await form.onsubmit({preventDefault(){}});assert.equal(error.textContent,'Use um montante sustentado.');assert(error.focused);assert.equal(status.textContent,'');assert.equal(location.reloads,0);
fail=false;await form.onsubmit({preventDefault(){}});assert.equal(keys[0],keys[1]);assert.equal(location.hash,'payments');assert.equal(location.reloads,1);
}
for(const mode of ['compras','sgf']) {
const base=`http://127.0.0.1:${mode==='compras'?8007:8008}`;
const obligations=mode==='compras'?'/obrigacoes-fornecedores':'/supplier-payables';
function client(){const cookies=new Map();let csrf='',context='';return {
async request(path,method='GET',body,form=false,key){
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,...(key?{'Idempotency-Key':key}:{})},...(body===undefined?{}:{body:form?new URLSearchParams(body):JSON.stringify(body)})});
for(const c of r.headers.getSetCookie()){const p=c.split(';',1)[0],i=p.indexOf('=');cookies.set(p.slice(0,i),p.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,location:r.headers.get('location'),html,data};
},
async enter(role,next='obligations'){const page=await this.request('/demo?next='+next);assert.equal(page.status,200);assert(page.html.includes(`name="next" value="${next}"`));const r=await this.request('/demo/entrar','POST',{role,next,_token:csrf},true);assert.equal(r.status,302);assert.equal(new URL(r.location,base).pathname,next==='payments'?'/supplier-payments':obligations);if(mode==='sgf')assert.equal((await this.request('/api/session')).status,200);},
async page(path,action){const r=await this.request(path);assert.equal(r.status,200);if(action)assert(r.html.includes(`data-action="${action}"`),mode+' missing form '+action);return r.html;},
async submit(api,action,body){const r=await this.request(api+'/'+action,'POST',body,false,randomUUID());assert.equal(r.status,200,mode+' '+action+' '+r.html.slice(0,180));return r.data;},
async position(){const r=await this.request('/api/supplier-payments');assert.equal(r.status,200);return r.data;},
async leave(){await this.page('/demo');const r=await this.request('/demo/sair','POST',{_token:csrf},true);assert.equal(r.status,302);}
};}
const p=client(),v=client(),q=client();await p.enter('preparacao');await v.enter('validacao');await q.enter('consulta');
let html=await p.page(obligations,'prepare');const form=html.match(/<form data-action="prepare">([\s\S]*?)<\/form>/)[1];const sourceId=Number(form.match(/<option value="(\d+)"/)[1]);
const proposal=await p.submit('/api/supplier-payables','prepare',{id:sourceId,reason:'Percurso sintético completo pelas páginas de ensaio'});
await v.page(obligations,'validate');await v.submit('/api/supplier-payables','validate',{id:proposal.proposal_id,reason:'Conferência independente da origem e valores documentais'});
let data=await q.position();let obligation=data.obligations[0];assert.equal(obligation.principal_minor,1160000);assert.equal(obligation.balance_minor,1160000);assert.equal(obligation.due_on,null);assert.equal(obligation.days_overdue,null);
const stages=[{stage:'recognised',debt_minor:obligation.balance_minor}];
await p.enter('preparacao','payments');html=await p.page('/supplier-payments','payment-prepare');assert(html.includes('/demo?next=payments'));assert(html.includes('Versão '+obligation.source_revision));
await checkFormFeedback(html);
const paymentBody={obligation_id:obligation.id,source_revision:obligation.source_revision,amount:'4000.00',currency:'MZN',paid_on:new Date().toISOString().slice(0,10),fact_reference:'SIM-INTERFACE-PAY-001',evidence_reference:'SIM-INTERFACE-EVID-001',reason:'Pagamento sintético de 4 000 MZN sem execução bancária'};
const payment=await p.submit('/api/supplier-payments','payment-prepare',paymentBody);assert.equal((await p.submit('/api/supplier-payments','payment-prepare',paymentBody)).payment_id,payment.payment_id);
data=await q.position();assert.equal(data.obligations[0].balance_minor,1160000);assert.equal(data.payments[0].state,'proposed');
await v.enter('validacao','payments');await v.page('/supplier-payments','payment-decide');const decision={payment_id:payment.payment_id,decision:'confirmed',evidence_reference:'SIM-INTERFACE-CONF-001',reason:'Conferência independente de evidência artificial'};
const paymentDecision=await v.submit('/api/supplier-payments','payment-decide',decision);
assert.equal((await v.submit('/api/supplier-payments','payment-decide',decision)).decision_id,paymentDecision.decision_id);
data=await q.position();assert.equal(data.obligations[0].balance_minor,1160000);assert.equal(data.payments[0].unallocated_minor,400000);stages.push({stage:'payment_confirmed_before_allocation',debt_minor:1160000,unallocated_minor:400000});
html=await q.page(obligations);assert(/Saldo por liquidar: 11 600\.00 MZN/.test(html));
await p.page('/supplier-payments','allocation-prepare');const plan={payment_id:payment.payment_id,reference:'SIM-INTERFACE-ALLOC-001',allocations:[{obligation_id:obligation.id,source_revision:obligation.source_revision,amount:'4000.00'}],reason:'Afectação explícita sem segundo efeito na obrigação'};
const allocation=await p.submit('/api/supplier-payments','allocation-prepare',plan);assert.equal((await q.position()).obligations[0].balance_minor,1160000);
await v.page('/supplier-payments','allocation-decide');const confirmation={proposal_id:allocation.proposal_id,decision:'confirmed',reason:'Validação independente do saldo e afectação'};await v.submit('/api/supplier-payments','allocation-decide',confirmation);
assert.equal((await p.submit('/api/supplier-payments','allocation-prepare',plan)).proposal_id,allocation.proposal_id);await v.submit('/api/supplier-payments','allocation-decide',confirmation);
data=await q.position();obligation=data.obligations[0];assert.equal(obligation.principal_minor,1160000);assert.equal(obligation.balance_minor,760000);assert.equal(obligation.due_on,null);assert.equal(obligation.days_overdue,null);assert.equal(data.payments.length,1);assert.equal(data.allocations.length,1);assert.equal(data.payments[0].unallocated_minor,0);stages.push({stage:'allocation_confirmed',debt_minor:760000,unallocated_minor:0});
html=await q.page('/supplier-payments');assert(html.includes('7 600.00 MZN'));assert(html.includes('Atraso indeterminado'));assert(html.includes('Dias de atraso: sem valor'));assert(!html.includes('data-action='));
html=await q.page(obligations);assert(/Saldo por liquidar: 7 600\.00 MZN/.test(html));assert(html.includes('11 600.00 MZN'));assert(html.includes('4 000.00 MZN'));assert(html.includes('Atraso indeterminado'));
await p.page('/supplier-payments');assert(!(await p.page('/supplier-payments')).includes('data-action="allocation-prepare"'),'Exhausted payment should not be offered for new allocation');
const eventCounts=Object.fromEntries([...new Set(data.events.map(e=>e.kind))].map(kind=>[kind,data.events.filter(e=>e.kind===kind).length]));
assert.equal(data.events.length,4);assert(Object.values(eventCounts).every(count=>count===1));
const paymentEvent=data.events.find(e=>e.kind==='supplier_payment.confirmed'),allocationEvent=data.events.find(e=>e.kind==='supplier_allocation.confirmed');
assert(paymentEvent);assert(allocationEvent);
assert.notEqual(String(data.payments[0].prepared_by),String(paymentEvent.actor_id));
const allocationProposal=data.allocation_proposals.find(item=>Number(item.id)===allocation.proposal_id);
assert.notEqual(String(allocationProposal.prepared_by),String(allocationEvent.actor_id));
const eventIds=data.events.map(e=>e.id);
await q.leave();await q.enter('consulta','payments');data=await q.position();assert.equal(data.obligations[0].balance_minor,760000);assert.equal(data.allocations.length,1);assert.deepEqual(data.events.map(e=>e.id),eventIds);
await q.page('/demo?next=payments');const bad=await q.request('/demo/entrar','POST',{role:'consulta',next:'https://example.test'},true);assert.equal(bad.status,422);
await p.leave();await v.leave();await q.leave();
hosts.push({host:mode,passed:true,kind:'HTTP pages, form availability, submitted commands and JavaScript syntax; not browser execution',stages,principal_minor:1160000,due_on:null,days_overdue:null,payments:1,allocations:1,repeat_recovered:true,payment_confirmation_repeat_recovered:true,event_counts:eventCounts,distinct_validation_actors:true,history_after_reentry:true,canonical_balance_on_both_pages:true,role_destination_retained:true,external_redirect_refused:true,read_only_without_forms:true,visual_review:false});
console.log(mode+': full synthetic sequence passed; single payment/allocation, debt 11600 before allocation and 7600 after.');
}
const core=await fs.readFile('packages/payables/src/SupplierSettlements.php');const coreSha=createHash('sha256').update(core).digest('hex');const accepted=JSON.parse(await fs.readFile('docs/proofs/supplier-settlements.json','utf8'));const unchanged=accepted.hosts.every(h=>h.core_sha256===coreSha);assert(unchanged||process.env.SETTLEMENTS_REVERSAL_REGRESSION==='1','Financial core changed since accepted proof');
await fs.writeFile(process.env.SETTLEMENTS_REVERSAL_REGRESSION==='1'?'docs/proofs/supplier-reversals-baseline-20261030.json':'docs/proofs/supplier-settlements-consolidation-20261030.json',JSON.stringify({checked_at:new Date().toISOString(),core_unchanged:unchanged,core_sha256:coreSha,hosts,visual_blocker:'Previous Firefox attempt stopped: browser URL policy enforcement is not yet supported for the current Windows browser. Browser not retried in this consolidation.',public_enabled:false,bank_execution:false,accepted_proof_preserved:true},null,2)+'\n');