This commit is contained in:
2026-10-30 23:20:27 +02:00
parent 2c0be7ac5e
commit 680959cc87
19 changed files with 1528 additions and 15 deletions
+41
View File
@@ -0,0 +1,41 @@
import pathlib,subprocess,json,os,hashlib,datetime
plan=PLAN
expected=EXPECTED_HASHES
root=pathlib.Path('/var/www/html/sgf.tempo.co.mz')
for f,h in expected.items():
if hashlib.sha256((root/f).read_bytes()).hexdigest()!=h:raise RuntimeError('Authorisation source changed; stop before writing: '+f)
assert plan['target']['id']==1 and plan['target']['email']=='nala@tempo.co.mz' and len(plan['entries'])==82
php=r'''
chdir('/var/www/html/sgf.tempo.co.mz');require 'vendor/autoload.php';$p=json_decode(stream_get_contents(STDIN),true,64,JSON_THROW_ON_ERROR);$a=require 'bootstrap/cache/config.php';$c=$a['database']['connections'][$a['database']['default']];if($c['database']!=='sgf_db'||$a['app']['env']!=='production'||$a['app']['url']!=='https://sgf.tempo.co.mz')throw new Exception('Destination changed');
$db=new PDO('pgsql:host='.$c['host'].';port='.$c['port'].';dbname='.$c['database'],$c['username'],$c['password'],[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]);$db->beginTransaction();$db->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');$db->exec("SET LOCAL lock_timeout='5s'");$db->exec("SET LOCAL statement_timeout='30s'");$db->exec('SELECT pg_advisory_xact_lock(72644101)');$db->exec('LOCK TABLE permission_grants IN SHARE ROW EXCLUSIVE MODE');
$q=$db->query('select id,name,email,auth_version from users where id=1 for update');$u=$q->fetch(PDO::FETCH_ASSOC);if(!$u||$u['email']!=='nala@tempo.co.mz'||$u['name']!=='Osmane Nala')throw new Exception('Identity changed');
foreach(['tenants'=>['name'=>'Tempo'],'companies'=>['name'=>'Tempo, Lda.','tenant_id'=>1],'units'=>['name'=>'SEGI','tenant_id'=>1,'company_id'=>1]] as $t=>$fields){$row=$db->query('select * from '.$t.' where id=1 for update')->fetch(PDO::FETCH_ASSOC);foreach($fields as $k=>$v)if(!$row||$row[$k]!=$v)throw new Exception('Scope changed');}
if(!$db->query('select 1 from company_memberships where user_id=1 and tenant_id=1 and company_id=1')->fetchColumn())throw new Exception('Membership changed');
$allBefore=$db->query('select * from permission_grants order by id')->fetchAll(PDO::FETCH_ASSOC);$before=array_values(array_filter($allBefore,fn($g)=>$g['user_id']==1&&$g['tenant_id']==1&&$g['company_id']==1));if(count($before)!==6)throw new Exception('Expected six original rights; review changed state before retry');
$wanted=[];foreach($p['entries'] as $e){if($e['tenant_id']!==1||$e['company_id']!==1||!in_array($e['unit_id'],[null,1],true))throw new Exception('Out-of-scope manifest');$k=$e['permission'].'@'.($e['unit_id']??'company');if(isset($wanted[$k]))throw new Exception('Duplicate manifest right');$wanted[$k]=$e;}
foreach($before as $g){$k=$g['permission'].'@'.($g['unit_id']??'company');if(!isset($wanted[$k])||!$wanted[$k]['already_granted'])throw new Exception('Original grant changed');}
if(getenv('SGF_ACCESS_MODE')==='preview'){$db->rollBack();echo json_encode(['preview_passed'=>true,'existing'=>6,'expected_additions'=>76,'target'=>$u,'effective_database'=>'sgf_db']);exit;}
$tables=$db->query("select tablename from pg_tables where schemaname='public' order by tablename")->fetchAll(PDO::FETCH_COLUMN);$count=function()use($db,$tables){$r=[];foreach($tables as $t)$r[$t]=(int)$db->query('select count(*) from "'.str_replace('"','""',$t).'"')->fetchColumn();return $r;};$countsBefore=$count();$q=$db->prepare('insert into permission_grants (user_id,tenant_id,company_id,unit_id,permission,created_at,updated_at) values (1,1,1,?,?,now(),now()) returning id');$added=[];
foreach($wanted as $k=>$e){$matches=array_filter($before,fn($g)=>$g['permission']===$e['permission']&&($g['unit_id']===null?null:(int)$g['unit_id'])===$e['unit_id']);if($matches)continue;$q->execute([$e['unit_id'],$e['permission']]);$added[]=['id'=>(int)$q->fetchColumn(),'permission'=>$e['permission'],'unit_id'=>$e['unit_id']];}if(count($added)!==76)throw new Exception('Unexpected addition count');
$q=$db->prepare('insert into identity_audit (action,actor_id,tenant_id,company_id,unit_id,subject_id,details,created_at) values (?,?,?,?,?,?,?,now()) returning id');$q->execute(['identity.functional_access_package_granted',1,1,1,1,1,json_encode(['operator'=>'Tempo — confirmação expressa do pacote de 82 direitos em 30/10/2026','reason'=>'SGF-FUNCTIONAL-DEVELOPMENT-ACCESS-PROPOSAL-20261030; conservar seis direitos e acrescentar 76; sem levantar guardas fiscais, bancárias ou de QA; manifesto SHA256 '.$p['approved_manifest_sha256'],'grants'=>array_map(fn($e)=>['permission'=>$e['permission'],'unit_id'=>$e['unit_id']],$added)],JSON_THROW_ON_ERROR)]);$audit=(int)$q->fetchColumn();
$allAfter=$db->query('select * from permission_grants order by id')->fetchAll(PDO::FETCH_ASSOC);$byId=array_column($allAfter,null,'id');foreach($allBefore as $g)if(($byId[$g['id']]??null)!==$g)throw new Exception('Previous grant changed');$actual=array_values(array_filter($allAfter,fn($g)=>$g['user_id']==1&&$g['tenant_id']==1&&$g['company_id']==1));if(count($actual)!==82)throw new Exception('Final package size mismatch');foreach($actual as $g)if(!isset($wanted[$g['permission'].'@'.($g['unit_id']??'company')]))throw new Exception('Extra grant detected');
$afterCounts=$count();foreach($countsBefore as $t=>$n){$delta=$t==='permission_grants'?76:($t==='identity_audit'?1:0);if($afterCounts[$t]!==$n+$delta)throw new Exception('Unexpected table effect');}
$db->commit();echo json_encode(['applied'=>true,'database'=>'sgf_db','user'=>['id'=>1,'name'=>$u['name'],'email'=>$u['email']],'tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'existing_preserved'=>6,'added'=>76,'final'=>82,'added_grants'=>$added,'audit_id'=>$audit,'all_previous_grants_preserved'=>true,'other_table_counts_preserved'=>true,'public_tables_checked'=>count($tables),'schema_changes'=>0,'credential_changes'=>0,'qa_guards_removed'=>false,'public_payables_enabled'=>false,'fiscal_real_enabled'=>false,'bank_execution_enabled'=>false]);
'''
body=dict(plan);body['approved_manifest_sha256']=MANIFEST_SHA
payload=json.dumps(body);env=dict(os.environ);env['SGF_ACCESS_MODE']='preview'
r=subprocess.run(['php','-r',php],input=payload,capture_output=True,text=True,env=env)
if r.returncode:raise RuntimeError('Readiness verification failed; no apply attempted; output withheld')
preview=json.loads(r.stdout)
if MODE=='preview':print(json.dumps(preview));raise SystemExit(0)
backup=pathlib.Path('/var/backups/sgf-functional-access-20261030');backup.mkdir(mode=0o700,exist_ok=True);os.chmod(backup,0o700);archive=backup/'permissions-audit-before.dump'
if archive.exists():raise RuntimeError('Existing operation backup; inspect receipt before any retry')
r=subprocess.run(['runuser','-u','postgres','--','pg_dump','-Fc','--dbname=sgf_db','-t','public.permission_grants','-t','public.identity_audit'],capture_output=True,check=True);fd=os.open(archive,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
with os.fdopen(fd,'wb') as f:f.write(r.stdout)
subprocess.run(['pg_restore','--list',str(archive)],capture_output=True,check=True)
# Validate restore syntax and content readability; do not restore over live history.
subprocess.run(['pg_restore','--file=-',str(archive)],capture_output=True,check=True)
env['SGF_ACCESS_MODE']='apply';r=subprocess.run(['php','-r',php],input=payload,capture_output=True,text=True,env=env)
if r.returncode:raise RuntimeError('Transaction refused/rolled back; inspect current grants before retry; sensitive output withheld')
receipt=json.loads(r.stdout);receipt.update(checked_at=datetime.datetime.now(datetime.timezone.utc).isoformat(),manifest_sha256=MANIFEST_SHA,release=str(root.resolve()),backup=str(archive),backup_catalogue_and_sql_readable=True,restore_over_live_history=False)
f=backup/'receipt.json';f.write_text(json.dumps(receipt,ensure_ascii=False,indent=2));os.chmod(f,0o600);print(json.dumps(receipt))
@@ -0,0 +1,59 @@
import pathlib,json,subprocess,hashlib,os,datetime,re
plan=PLAN
expected=EXPECTED
root=pathlib.Path('/var/www/html/sgf.tempo.co.mz')
for f,h in expected.items():
if hashlib.sha256((root/f).read_bytes()).hexdigest()!=h:raise RuntimeError('Source changed; refuse')
def pg(sql):
r=subprocess.run(['runuser','-u','postgres','--','psql','-X','-At','-v','ON_ERROR_STOP=1','--dbname=sgf_db'],input=sql,capture_output=True,text=True)
if r.returncode:raise RuntimeError('Scoped SQL failed: '+r.stderr[-1200:])
return r.stdout
row=json.loads(pg("BEGIN READ ONLY; SELECT json_build_object('count',(select count(*) from permission_grants where user_id=1 and company_id=1 and tenant_id=1),'definition',pg_get_constraintdef(oid)) FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check';ROLLBACK;").splitlines()[1])
assert row['count']==6
old=row['definition'];assert old.startswith('CHECK (') and old.endswith(')');expression=old[7:-1] if old.startswith('CHECK ((') else old[7:-1]
# Strip exactly CHECK ( ... ), retaining balanced internal parentheses.
expression=old[len('CHECK ('):-1]
allowed=set(re.findall(r"'([a-z_]+\.[a-z_.]+)'",old));missing=sorted({e['permission'] for e in plan['entries']}-allowed)
expectedMissing=['purchasing_budget.approve','purchasing_budget.consume','purchasing_budget.create','purchasing_budget.record','purchasing_budget.reinforcement.propose','purchasing_budget.reinforcement.review','purchasing_budget.view','supplier_payables.view','workshop_corrections.apply','workshop_corrections.request','workshop_corrections.review','workshop_corrections.view']
assert missing==sorted(expectedMissing),'Unexpected constraint delta'
q=lambda x:"'"+str(x).replace("'","''")+"'"
new_expression='('+expression+') OR (permission IN ('+','.join(q(x) for x in missing)+') AND unit_id IS NOT NULL)'
values=','.join('('+q(e['permission'])+','+('NULL::bigint' if e['unit_id'] is None else '1::bigint')+')' for e in plan['entries'])
wrong=','.join('('+q(e['permission'])+','+('1::bigint' if e['unit_id'] is None else 'NULL::bigint')+')' for e in plan['entries'])
assert pg('SELECT bool_and('+new_expression+') FROM (VALUES '+values+') AS p(permission,unit_id);').strip()=='t'
assert pg('SELECT bool_and(NOT ('+new_expression+')) FROM (VALUES '+wrong+') AS p(permission,unit_id);').strip()=='t'
backup=pathlib.Path('/var/backups/sgf-functional-access-20261030');backup.mkdir(mode=0o700,exist_ok=True);os.chmod(backup,0o700)
archive=backup/'permissions-audit-before-final.dump'
if archive.exists():raise RuntimeError('Final backup already exists; inspect receipt before retry')
r=subprocess.run(['runuser','-u','postgres','--','pg_dump','-Fc','--dbname=sgf_db','-t','public.permission_grants','-t','public.identity_audit'],capture_output=True,check=True)
with os.fdopen(os.open(archive,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600),'wb') as f:f.write(r.stdout)
subprocess.run(['pg_restore','--list',str(archive)],capture_output=True,check=True);subprocess.run(['pg_restore','--file=-',str(archive)],capture_output=True,check=True)
oldFile=backup/'constraint-before.sql';oldFile.write_text('ALTER TABLE public.permission_grants ADD CONSTRAINT permission_grants_scope_check '+old+';\n');os.chmod(oldFile,0o600)
manifest=json.dumps([{'permission':e['permission'],'unit_id':e['unit_id']} for e in plan['entries']]);detail=json.dumps({'operator':'Tempo — confirmação expressa de 82 direitos em 30/10/2026','reason':'Pacote aprovado; acrescentar 76 e preservar seis. Adaptação estrita da lista de 12 direitos aprovados ausentes no catálogo; sem tabelas/guardas QA ou operações fiscais/bancárias. Manifesto SHA256 '+MANIFEST_SHA,'grants':json.loads(manifest)})
sql='''BEGIN ISOLATION LEVEL REPEATABLE READ;
SET LOCAL lock_timeout='5s';SET LOCAL statement_timeout='30s';
SELECT pg_advisory_xact_lock(72644101);
LOCK TABLE permission_grants IN ACCESS EXCLUSIVE MODE;
CREATE TEMP TABLE intended_access ON COMMIT DROP AS SELECT * FROM jsonb_to_recordset('''+q(manifest)+'''::jsonb) AS x(permission text,unit_id bigint);
CREATE TEMP TABLE access_before ON COMMIT DROP AS SELECT * FROM permission_grants;
CREATE TEMP TABLE audit_before_count ON COMMIT DROP AS SELECT count(*) AS n FROM identity_audit;
DO $guard$ BEGIN
IF (SELECT count(*) FROM intended_access)<>82 OR (SELECT count(*) FROM permission_grants WHERE user_id=1 AND tenant_id=1 AND company_id=1)<>6 THEN RAISE EXCEPTION 'Changed grant state'; END IF;
IF NOT EXISTS(SELECT 1 FROM users WHERE id=1 AND email='nala@tempo.co.mz' AND name='Osmane Nala') OR NOT EXISTS(SELECT 1 FROM tenants WHERE id=1 AND name='Tempo') OR NOT EXISTS(SELECT 1 FROM companies WHERE id=1 AND tenant_id=1 AND name='Tempo, Lda.') OR NOT EXISTS(SELECT 1 FROM units WHERE id=1 AND tenant_id=1 AND company_id=1 AND name='SEGI') OR NOT EXISTS(SELECT 1 FROM company_memberships WHERE user_id=1 AND tenant_id=1 AND company_id=1) THEN RAISE EXCEPTION 'Identity/scope changed'; END IF;
IF EXISTS(SELECT 1 FROM permission_grants g WHERE g.user_id=1 AND g.tenant_id=1 AND g.company_id=1 AND NOT EXISTS(SELECT 1 FROM intended_access x WHERE x.permission=g.permission AND x.unit_id IS NOT DISTINCT FROM g.unit_id)) THEN RAISE EXCEPTION 'Unapproved existing grant'; END IF;
END $guard$;
ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check;
ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ('''+new_expression+''');
INSERT INTO permission_grants(user_id,tenant_id,company_id,unit_id,permission,created_at,updated_at)
SELECT 1,1,1,x.unit_id,x.permission,now(),now() FROM intended_access x WHERE NOT EXISTS(SELECT 1 FROM permission_grants g WHERE g.user_id=1 AND g.tenant_id=1 AND g.company_id=1 AND g.permission=x.permission AND g.unit_id IS NOT DISTINCT FROM x.unit_id);
INSERT INTO identity_audit(action,actor_id,tenant_id,company_id,unit_id,subject_id,details,created_at) VALUES ('identity.functional_access_package_granted',1,1,1,1,1,'''+q(detail)+'''::jsonb,now());
DO $verify$ BEGIN
IF (SELECT count(*) FROM permission_grants WHERE user_id=1 AND tenant_id=1 AND company_id=1)<>82 OR (SELECT count(*) FROM permission_grants)<>(SELECT count(*)+76 FROM access_before) OR (SELECT count(*) FROM identity_audit)<>(SELECT n+1 FROM audit_before_count) THEN RAISE EXCEPTION 'Unexpected delta'; END IF;
IF EXISTS(SELECT * FROM access_before EXCEPT SELECT * FROM permission_grants) THEN RAISE EXCEPTION 'Previous grants modified'; END IF;
IF EXISTS(SELECT 1 FROM permission_grants g WHERE g.user_id=1 AND g.tenant_id=1 AND g.company_id=1 AND NOT EXISTS(SELECT 1 FROM intended_access x WHERE x.permission=g.permission AND x.unit_id IS NOT DISTINCT FROM g.unit_id)) THEN RAISE EXCEPTION 'Unapproved resulting grant'; END IF;
END $verify$;
COMMIT;
SELECT json_build_object('applied',true,'final',(select count(*) from permission_grants where user_id=1 and tenant_id=1 and company_id=1),'company',(select count(*) from permission_grants where user_id=1 and tenant_id=1 and company_id=1 and unit_id is null),'unit',(select count(*) from permission_grants where user_id=1 and tenant_id=1 and company_id=1 and unit_id=1),'audit_id',(select max(id) from identity_audit where action='identity.functional_access_package_granted'));
'''
output=pg(sql);receipt=json.loads(output.strip().splitlines()[-1]);receipt.update(existing_preserved=6,added=76,approved_manifest_sha256=MANIFEST_SHA,scope={'tenant_id':1,'company_id':1,'unit_id':1},target='nala@tempo.co.mz',backup=str(archive),backup_catalogue_and_sql_readable=True,constraint_added_unit_names=missing,wrong_scope_candidates_rejected=82,qa_guards_unchanged=True,public_payables_enabled=False,bank_execution=False,real_fiscal_emission=False,checked_at=datetime.datetime.now(datetime.timezone.utc).isoformat(),release=str(root.resolve()),initial_attempt_rolled_back=True,restored_over_live_history=False)
f=backup/'receipt.json';f.write_text(json.dumps(receipt,ensure_ascii=False,indent=2));os.chmod(f,0o600);print(json.dumps(receipt))
+7
View File
@@ -0,0 +1,7 @@
import fs from 'node:fs/promises';import assert from 'node:assert/strict';import{createServer}from'vite';import{createHash}from'node:crypto';import React from'react';import{renderToStaticMarkup}from'react-dom/server';
const plan=JSON.parse(await fs.readFile('docs/SGF_FUNCTIONAL_ACCESS_PROPOSAL_20261030.json','utf8'));const check=JSON.parse(await fs.readFile('docs/proofs/functional-access-server-checks-20261030.json','utf8'));assert.equal(check.grants.length,82);for(const e of plan.entries)assert(check.grants.some(g=>g.permission===e.permission&&g.unit_id===e.unit_id));assert(Object.values(check.checks).every(v=>v.status==='ok'));assert(Object.values(check.guards).every(v=>v.refused===true&&v.status===403));
const server=await createServer({server:{middlewareMode:true},appType:'custom'});let before,after,nav;
try{const{navigationGroups,navigationTopics,default:Navigation}=await server.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');const canFrom=gs=>(p,u=null)=>gs.some(g=>g.permission===p&&g.unit_id===u);before=navigationTopics(navigationGroups(canFrom(plan.entries.filter(e=>e.already_granted)),1)).map(t=>t.label);const groups=navigationGroups(canFrom(check.grants),1);const topics=navigationTopics(groups);after=topics.map(t=>t.label);assert.deepEqual(after,['Início','Comercial','Compras','Stock','Oficinas','Financeiro','Contabilidade','Configuração']);assert(topics.find(t=>t.id==='commercial').sections.some(g=>g.id==='billing'&&g.items.some(i=>i.id==='service_invoices')));const ids=groups.flatMap(g=>g.items.map(i=>i.id));assert.equal(new Set(ids).size,ids.length);nav=renderToStaticMarkup(React.createElement(Navigation,{groups,page:'home',navigate:()=>{},user:{name:'Conta autorizada',email:'nala@tempo.co.mz'},busy:false,logout:()=>{}}));assert(nav.includes('topic-commercial'));}finally{await server.close();}
const root=await fetch('https://sgf.tempo.co.mz/',{signal:AbortSignal.timeout(15000)});assert.equal(root.status,200);const html=await root.text();const urls=[...new Set([...html.matchAll(/(?:src|href)="([^"]*\/build\/[^" ]+)"/g)].map(m=>m[1]))];const assets=[];for(const url of urls){const r=await fetch(url,{signal:AbortSignal.timeout(15000)});assert.equal(r.status,200);const bytes=Buffer.from(await r.arrayBuffer());const local=await fs.readFile('public'+new URL(url).pathname);assert(bytes.equals(local));assets.push({path:new URL(url).pathname,identical_local:true,sha256:createHash('sha256').update(bytes).digest('hex')});}
const demos=[];for(const url of ['http://127.0.0.1:8002/avaliar','http://127.0.0.1:8003/','http://127.0.0.1:8005/demo','http://127.0.0.1:8006/demo']){const r=await fetch(url,{signal:AbortSignal.timeout(10000)});demos.push({url,status:r.status});assert.equal(r.status,200);}
const proof={checked_at:new Date().toISOString(),manifest_matches_all_82:true,menus_before:before,menus_after:after,commercial_contains_billing_and_invoices:true,duplicates:false,public_assets:assets,demo_entries:demos,verification:'public HTTP/asset comparison, static navigation with effective grants, and server read-only probes; no interactive authenticated browser test',visual_review:false,public_test_writes:false};await fs.writeFile('docs/proofs/functional-access-navigation-20261030.json',JSON.stringify(proof,null,2)+'\n');console.log(JSON.stringify({menus:after,account_rights:82,server_checks:Object.keys(check.checks).length,guards_preserved:check.guards,demo_entries:demos}));
+9 -7
View File
@@ -20,23 +20,25 @@ $guardDemo = static function (): void {
Route::middleware('web')->group(function () use ($guardDemo, $isSgf, $destination) {
Route::get('/demo', function (Request $r) use ($guardDemo, $isSgf, $destination) {
$guardDemo();
$next = $r->query('next') === 'payments' ? 'payments' : 'obligations';
$continue = $next === 'payments' ? '/supplier-payments' : $destination;
$e = static fn ($v) => htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$title = $isSgf ? 'SGF — demonstração' : 'Compras — demonstração';
$html = '<!doctype html><html lang="pt"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>'.$title.'</title><style>body{font:18px system-ui;background:#f3f6f5;color:#142b28;margin:0}main{max-width:850px;margin:auto;padding:30px}section{background:white;padding:24px;border-radius:12px;margin:20px 0}button,a{font:inherit}button{padding:14px;background:#125a4b;color:white;border:0;border-radius:8px;cursor:pointer}form{margin:18px 0}a{color:#125a4b}</style><main><h1>'.$title.'</h1><p>Entre para experimentar e identificar melhorias. Não precisa de conta nem de palavra-passe.</p><section><h2>Escolha a função que quer experimentar</h2><p>Todos os dados são sintéticos. A função escolhida determina as acções disponíveis.</p>';
foreach (['preparacao' => ['Preparar', 'Consultar a factura e propor a obrigação.'], 'validacao' => ['Validar', 'Rever a proposta preparada por outra identidade sintética.'], 'consulta' => ['Consultar', 'Explorar valores, origem e histórico, sem alterar dados.']] as $role => [$label, $description]) {
$html .= '<form method="post" action="/demo/entrar"><input type="hidden" name="_token" value="'.$e(csrf_token()).'"><input type="hidden" name="role" value="'.$role.'"><button>'.$label.'</button> '.$description.'</form>';
foreach (['preparacao' => ['Preparar', 'Consultar a factura e propor a obrigação, o pagamento sintético ou a afectação.'], 'validacao' => ['Validar', 'Rever e decidir propostas preparadas por outra identidade sintética.'], 'consulta' => ['Consultar', 'Explorar valores, origem e histórico, sem alterar dados.']] as $role => [$label, $description]) {
$html .= '<form method="post" action="/demo/entrar"><input type="hidden" name="_token" value="'.$e(csrf_token()).'"><input type="hidden" name="role" value="'.$role.'"><input type="hidden" name="next" value="'.$next.'"><button>'.$label.'</button> '.$description.'</form>';
}
$html .= '</section><section><h2>O que experimentar</h2><p>Factura: base de 10 000 MZN + imposto documental de 1 600 MZN = 11 600 MZN. Preparar, trocar para validar e consultar o resultado. Repetir deve recuperar a mesma obrigação.</p><p>O vencimento permanece pendente e o atraso indeterminado. Este percurso não executa pagamentos.</p><p>As funções usam identidades de demonstração partilhadas; não identificam a pessoa real que está a testar. O cenário e o histórico são partilhados e conservados entre visitas.</p></section>';
$html .= '</section><section><h2>O que experimentar</h2><p>Factura: base de 10 000 MZN + imposto documental de 1 600 MZN = 11 600 MZN. Preparar, trocar para validar e consultar o resultado. Repetir deve recuperar a mesma obrigação.</p><p>Depois, em Pagamentos e afectações: preparar 4 000 MZN, confirmar com outra identidade e conferir que a dívida continua em 11 600 MZN. Só a afectação confirmada de 4 000 MZN reduz o saldo para 7 600 MZN.</p><p>O vencimento permanece pendente e o atraso indeterminado. Não há execução bancária. Datas e referências do ensaio devem ser sintéticas; o cenário partilhado conserva as operações anteriores, sem reiniciar os valores a cada visita.</p><p>As funções usam identidades de demonstração partilhadas; não identificam a pessoa real que está a testar. O cenário e o histórico são partilhados e conservados entre visitas.</p></section>';
if ($r->user()) {
$html .= '<p>Sessão actual: '.$e($r->user()->name).' · <a href="'.$destination.'">Continuar o percurso</a> · <a href="/">Abrir a aplicação</a></p><form method="post" action="/demo/sair"><input type="hidden" name="_token" value="'.$e(csrf_token()).'"><button>Sair da demonstração</button></form>';
$html .= '<p>Sessão actual: '.$e($r->user()->name).' · <a href="'.$continue.'">Continuar o percurso</a> · <a href="/">Abrir a aplicação</a></p><form method="post" action="/demo/sair"><input type="hidden" name="_token" value="'.$e(csrf_token()).'"><button>Sair da demonstração</button></form>';
}
return response($html.'</main></html>')->header('Cache-Control', 'no-store')->header('X-Robots-Tag', 'noindex, nofollow');
});
Route::post('/demo/entrar', function (Request $r) use ($guardDemo, $isSgf, $destination) {
$guardDemo();
$d = $r->validate(['role' => 'required|in:preparacao,validacao,consulta']);
abort_if(array_diff(array_keys($r->all()), ['role', '_token']) !== [], 422);
$d = $r->validate(['role' => 'required|in:preparacao,validacao,consulta', 'next' => 'sometimes|in:payments,obligations']);
abort_if(array_diff(array_keys($r->all()), ['role', '_token', 'next']) !== [], 422);
// No arbitrary user identifier or permissions may be supplied by a visitor.
$u = User::where('email', $d['role'].'-obrigacoes@example.test')->firstOrFail();
$grants = DB::table('pa_access')->where(['user_id' => $u->id, 'action' => 'view'])->get();
@@ -58,7 +60,7 @@ Route::middleware('web')->group(function () use ($guardDemo, $isSgf, $destinatio
$r->session()->put('company_context', ['tenant_id' => (int) $g->tenant_id, 'company_id' => (int) $g->company_id, 'unit_id' => (int) $g->unit_id, 'token' => bin2hex(random_bytes(24))]);
\App\Modules\Identity\Services\Audit::record('demo.entered', $u->id, $g->tenant_id, $g->company_id, $g->unit_id, details: ['role' => $d['role'], 'synthetic' => true]);
}
return redirect($destination);
return redirect(($d['next'] ?? '') === 'payments' ? '/supplier-payments' : $destination);
})->middleware('throttle:30,1');
Route::post('/demo/sair', function (Request $r) use ($guardDemo) {
+62
View File
@@ -0,0 +1,62 @@
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);
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'};
await v.submit('/api/supplier-payments','payment-decide',decision);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');
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(data.events.length>=4);
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,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'));assert(accepted.hosts.every(h=>h.core_sha256===coreSha),'Financial core changed since accepted proof');
await fs.writeFile('docs/proofs/supplier-settlements-interface-20261030.json',JSON.stringify({checked_at:new Date().toISOString(),core_unchanged:true,core_sha256:coreSha,hosts,visual_blocker:'Computer Use native pipe is unavailable: failed to connect native pipe (OS error 2)',public_enabled:false,bank_execution:false,accepted_proof_preserved:true},null,2)+'\n');
+23
View File
@@ -0,0 +1,23 @@
import os,socket,subprocess,time,json,pathlib
root=pathlib.Path('C:/laragon/www/sgf-tempo');os.chdir(root)
php='C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe';node='C:/laragon/tools/sgf-tempo/node-v24.22.1-win-arm64/node.exe';processes=[];created=False
for port in [8007,8008]:
s=socket.socket();s.settimeout(.3);busy=s.connect_ex(('127.0.0.1',port))==0;s.close()
if busy:raise RuntimeError('Proof port occupied; no process stopped')
try:
subprocess.run([php,'scripts/payables-provision.php','--proof'],check=True);created=True
for mode,port in [('compras',8007),('sgf',8008)]:
settings=pathlib.Path(f'C:/laragon/data/sgf-tempo/secrets/payables-proof-{mode}.json');config=json.loads(settings.read_text(encoding='utf-8-sig'));env=dict(os.environ);env['PAYABLE_SETTINGS_FILE']=str(settings).replace('\\','/')
executable=php if mode=='compras' else 'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe'
p=subprocess.Popen([executable,'-S',f'127.0.0.1:{port}','-t','public',str(root/'scripts/payables-router.php')],cwd=config['HOST_ROOT'],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,creationflags=subprocess.CREATE_NO_WINDOW);processes.append(p)
for attempt in range(60):
s=socket.socket();s.settimeout(.2);ready=s.connect_ex(('127.0.0.1',port))==0;s.close()
if ready:break
time.sleep(.1)
if not ready:raise RuntimeError('Disposable HTTP host failed')
subprocess.run([php,'scripts/settlements-install.php',mode,'proof'],check=True)
subprocess.run([node,'scripts/settlements-interface-check.mjs'],check=True)
finally:
for p in processes:
if p.poll() is None:p.terminate();p.wait(timeout=15)
if created:subprocess.run([php,'scripts/payables-provision.php','--cleanup-proof'],check=True)
@@ -0,0 +1,4 @@
import fs from 'node:fs/promises';import assert from 'node:assert/strict';import vm from 'node:vm';import{randomUUID}from'node:crypto';
const s=await fs.readFile('scripts/settlements-interface-check.mjs','utf8');const definition=s.slice(s.indexOf(' function client()'),s.indexOf(' const p=client()'));const hosts=[];
for(const mode of ['compras','sgf']){const base=`http://127.0.0.1:${mode==='compras'?8005:8006}`,obligations=mode==='compras'?'/obrigacoes-fornecedores':'/supplier-payables';const make=eval('('+definition.trim().replace(/^function client/,'function')+')');const c=make();await c.enter('consulta','payments');const page=await c.page('/supplier-payments');assert(page.includes('/demo?next=payments'));assert(!page.includes('data-action='));const data=await c.position();await c.page(obligations);hosts.push({host:mode,entry_and_links:true,payment_records:data.payments.length,obligations:data.obligations.length,kind:'existing demo, query role only; no financial commands'});await c.leave();}
await fs.writeFile('docs/proofs/supplier-settlements-persistent-access-20261030.json',JSON.stringify({checked_at:new Date().toISOString(),hosts,visual_review:false},null,2)+'\n');console.log('Existing demonstration pages passed; no financial commands.');