This commit is contained in:
2026-10-29 22:42:37 +02:00
parent 78e89a51d2
commit 53ff46b0a2
52 changed files with 2082 additions and 79 deletions
+66
View File
@@ -0,0 +1,66 @@
<?php
// Read-only access inventory. Never prints passwords, hashes, keys or cookies.
$mode = $argv[1] ?? '';
$root = $mode === 'compras-main' || $mode === 'compras-payables' ? 'C:/laragon/www/compras' : 'C:/laragon/www/sgf-tempo';
if (! in_array($mode, ['sgf-main', 'compras-main', 'sgf-product', 'sgf-budget', 'sgf-payables', 'compras-payables'], true)) {
throw new LogicException('Unknown instance');
}
$manifest = null;
if (str_ends_with($mode, '-payables')) {
$host = str_starts_with($mode, 'compras') ? 'compras' : 'sgf';
$settings = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/payables-demo-'.$host.'.json'), true, 32, JSON_THROW_ON_ERROR);
$manifest = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/payables-demo-'.$host.'-access.json'), true, 32, JSON_THROW_ON_ERROR);
} elseif ($mode === 'sgf-budget') {
$settings = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/budget-demo.json'), true, 32, JSON_THROW_ON_ERROR);
} elseif ($mode === 'sgf-product') {
$settings = ['APP_ENV' => 'browser-testing'];
$manifest = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'), true, 64, JSON_THROW_ON_ERROR);
} else {
$settings = [];
}
foreach ($settings as $k => $v) {
putenv($k.'='.$v);
$_ENV[$k] = $_SERVER[$k] = $v;
}
require $root.'/vendor/autoload.php';
$app = require $root.'/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$db = \Illuminate\Support\Facades\DB::connection();
$db->beginTransaction();
try {
if ($db->getDriverName() === 'pgsql') {
$db->statement('SET TRANSACTION READ ONLY');
}
$accounts = [];
foreach (\App\Models\User::query()->orderBy('id')->get() as $u) {
$record = ['id' => $u->id, 'email' => $u->email, 'verified' => (bool) $u->email_verified_at];
if (str_contains($u->email, '@example.test')) {
$record['synthetic'] = true;
$candidate = null;
if (isset($manifest['accounts'])) {
foreach ($manifest['accounts'] as $role => $a) {
if ($a['email'] === $u->email) {
$record['role'] = $role;
$candidate = $a['password'];
}
}
} elseif (isset($manifest['password'])) {
$candidate = $manifest['password'];
}
if ($candidate !== null) {
$record['stored_demo_credential_matches'] = \Illuminate\Support\Facades\Hash::check($candidate, $u->password);
}
}
if (isset($u->tenant_id)) {
$record['tenant_id'] = (int) $u->tenant_id;
}
$accounts[] = $record;
}
echo json_encode(['instance' => $mode, 'environment' => app()->environment(), 'database' => $db->getDatabaseName(),
'effective_database' => $db->getDriverName() === 'pgsql' ? $db->selectOne('select current_database() as d')->d : $db->getDatabaseName(),
'session_cookie' => config('session.cookie'), 'session_domain' => config('session.domain'),
'accounts' => $accounts, 'read_only' => true], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;
} finally {
$db->rollBack();
}
+43
View File
@@ -0,0 +1,43 @@
// Checks existing synthetic credentials only; never resets passwords or creates users.
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
const reports=[];
// Existing integrated evaluation entry: verify all four roles without credentials.
for(const role of ['preparador','validador','tecnico','verificador']){
const cookies=new Map();
async function get(path,method='GET',body){
const r=await fetch('http://127.0.0.1:8002'+path,{method,redirect:'manual',headers:{Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),...(body?{'Content-Type':'application/x-www-form-urlencoded'}:{})},...(body?{body:new URLSearchParams(body)}:{})});
for(const c of r.headers.getSetCookie()){const pair=c.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
return {status:r.status,text:await r.text(),location:r.headers.get('location')};
}
const landing=await get('/avaliar');assert.equal(landing.status,200);
const token=landing.text.match(/name="_token" value="([^"]+)"/)?.[1];assert(token);
const entry=await get('/avaliar/entrar','POST',{role,_token:token});assert.equal(entry.status,302);assert.equal(new URL(entry.location, 'http://127.0.0.1:8002').pathname,'/','evaluation redirect');
assert.equal((await get('/')).status,200);
const session=JSON.parse((await get('/api/session')).text);assert(session.user);assert(session.context);
assert.equal((await get('/api/workshop')).status,200);
const logout=await fetch('http://127.0.0.1:8002/api/logout',{method:'POST',headers:{Accept:'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':session.csrf}});assert.equal(logout.status,200);
reports.push({host:'sgf-product',role,password_required:false,authorised_consultation:true,logout:true});
}
for(const mode of ['compras','sgf']){
const fixture=JSON.parse(await fs.readFile(`C:/laragon/data/sgf-tempo/secrets/payables-demo-${mode}-access.json`,'utf8'));
const base=`http://127.0.0.1:${mode==='compras'?8005:8006}`;
for(const [role,account] of Object.entries(fixture.accounts)){
const cookies=new Map();let csrf='',context='';
async function request(path,method='GET',body){
const r=await fetch(base+path,{method,redirect:'manual',headers:{Accept:'application/json','Content-Type':'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':csrf,'X-XSRF-TOKEN':decodeURIComponent(cookies.get('XSRF-TOKEN')??''),'X-SGF-Context':context},...(body?{body:JSON.stringify(body)}:{})});
for(const c of r.headers.getSetCookie()){const pair=c.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
const text=await r.text();let data={};try{data=JSON.parse(text);}catch{}
if(data.csrf)csrf=data.csrf;if(data.context?.token)context=data.context.token;
return {status:r.status,data};
}
assert.equal((await request(mode==='compras'?'/login':'/api/session')).status,200);
const login=await request(mode==='compras'?'/login':'/api/login','POST',{email:account.email,password:account.password});assert.equal(login.status,200,mode+' '+role+' existing login');
if(mode==='sgf')assert.equal((await request('/api/context','POST',fixture.scope)).status,200);
assert.equal((await request('/api/supplier-payables')).status,200);
assert.equal((await request(mode==='compras'?'/logout':'/api/logout','POST',{})).status,mode==='compras'?204:200);
reports.push({host:mode,role,existing_password_accepted:true,authorised_consultation:true,logout:true});
}
}
await fs.writeFile('docs/proofs/access-recovery.json',JSON.stringify({checked_at:new Date().toISOString(),results:reports,accounts_created:0,passwords_changed:0,permissions_changed:0,business_commands:0,visual_review:false},null,2)+'\n');
console.log('Integrated password-free SGF entry passed for four roles; six existing payables credentials accepted by their correct hosts. Consultation and logout passed.');
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {createServer} from 'vite';
const vite=await createServer({server:{middlewareMode:true},appType:'custom'});
try{
const {accountingSections,accountingFunctions,accountingReports,visibleAccountingSections,filterAccountingReports}=await vite.ssrLoadModule('/resources/js/accounting-menu.js');
const {default:Workspace}=await vite.ssrLoadModule('/resources/js/AccountingMenuWorkspace.jsx');
const {navigationGroups}=await vite.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
const context={token:'synthetic',company:{id:1,name:'Empresa sintética A'},unit:{id:7,name:'Unidade A'}};
const companyCan=p=>p==='company.view';
assert.equal(accountingSections.length,6);assert.equal(accountingFunctions.length,11);assert.equal(accountingReports.length,24);
assert.deepEqual(visibleAccountingSections(()=>false),[]);assert.deepEqual(visibleAccountingSections(p=>p==='access.manage'),[]);
assert.equal(visibleAccountingSections(p=>p==='catalogue_accounting.view').length,6);
const group=navigationGroups(companyCan,7).find(g=>g.id==='accounting');assert.equal(group.items.length,6);assert(group.items.every(i=>i.informational));
assert(!group.items.some(i=>i.id==='catalogue_accounting'));
const accountantCan=p=>companyCan(p)||p==='catalogue_accounting.view';
assert(navigationGroups(accountantCan,7).find(g=>g.id==='accounting').items.some(i=>i.id==='catalogue_accounting'));
assert(filterAccountingReports('depreciacoes').some(r=>r.id==='assets'));
assert(filterAccountingReports('fornecedores','Tesouraria').some(r=>r.id==='suppliers'));
assert.deepEqual(filterAccountingReports('fornecedores','Pessoal'),[]);
for(const section of accountingSections){
const render=can=>renderToStaticMarkup(React.createElement(Workspace,{section:section.id,context,can,navigate:()=>{}}));
const html=render(companyCan);assert(html.includes('Submenus de contabilidade'));assert(html.includes('Empresa sintética A'));
assert(!html.includes('<form'));assert(!html.includes('type="submit"'));assert(!html.includes('0.00 MZN'));
assert(!html.includes('Abrir plano e classificação existentes'));assert(!html.includes('Abrir recebimentos e reconciliação de teste'));
if(section.id==='accounting_configuration')assert(render(accountantCan).includes('Abrir plano e classificação existentes'));
const differentUnit=(p,u=null)=>p==='company.view'||(p==='service_receivables.view'&&u===8);
assert(!render(differentUnit).includes('Abrir recebimentos e reconciliação de teste'));
const other=renderToStaticMarkup(React.createElement(Workspace,{section:section.id,context:{...context,company:{id:2,name:'Empresa sintética B'}},can:companyCan,navigate:()=>{}}));assert(!other.includes('Empresa sintética A'));
}
const denied=renderToStaticMarkup(React.createElement(Workspace,{section:'accounting_panel',context,can:()=>false,navigate:()=>{}}));assert(!denied.includes('Submenus de contabilidade'));
await fs.writeFile('docs/proofs/accounting-menu.json',JSON.stringify({checked_at:new Date().toISOString(),sections:6,function_definitions:11,report_definitions:24,existing_paths_preserved:true,links_permission_scoped:true,unit_scoped_links_checked:true,no_permissions_assigned_by_profile:true,no_fabricated_financial_balances:true,report_search_checked:true,business_commands:0,backend_calls:0,visual_review:false,publication:false},null,2)+'\n');
console.log('Accounting menu passed: six groups, eleven functions, 24 report definitions, permission/unit scoped links, report search and no fabricated balances or business commands.');
}finally{await vite.close();}
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {createServer} from 'vite';
const vite=await createServer({server:{middlewareMode:true},appType:'custom'});
try{
console.log('Checking billing modules and rendering…');
const model=await vite.ssrLoadModule('/resources/js/billing-menu.js');
const {navigationGroups}=await vite.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
const {BillingDocumentList,default:Workspace}=await vite.ssrLoadModule('/resources/js/BillingMenuWorkspace.jsx');
const workshop=await vite.ssrLoadModule('/resources/js/workshop-menu.js');
const {default:WorkshopMenu}=await vite.ssrLoadModule('/resources/js/WorkshopMenuWorkspace.jsx');
const context={token:'synthetic',company:{name:'Empresa sintética'},unit:{id:7,name:'Unidade sintética'}};
const grants=[['service_invoices.view',7],['billing_drafts.view',7],['billing_drafts.create',7],['service_receivables.view',7],['issuer_documents.view',null]];
const can=(p,u=null)=>grants.some(g=>g[0]===p&&g[1]===u);
assert.deepEqual(navigationGroups(can,7).find(g=>g.id==='billing').items.map(i=>i.label),['Todos os documentos','Facturas','Facturas-recibo','Recibos','Notas de crédito','Orçamentos','Rascunhos']);
assert(!navigationGroups(()=>false,7).some(g=>g.id==='billing'));
assert(!navigationGroups(can,8).flatMap(g=>g.items).some(i=>['service_invoices','billing_drafts'].includes(i.id)));
assert(navigationGroups(can,7).flatMap(g=>g.items).some(i=>i.id==='service_invoice_setup'));
const techCan=(p,u=null)=>u===7&&['workshop.view','workshop.record'].includes(p);
const workshopCan=(p,u=null)=>techCan(p,u)||can(p,u)||(['clients.view','company.view','services.view'].includes(p)&&u===null);
assert.equal(workshop.visibleWorkshopSections(workshopCan,7).length,12);
assert(!workshop.visibleWorkshopSections(techCan,7).some(s=>['workshop_finance','workshop_clients'].includes(s.id)));
assert.deepEqual(workshop.visibleWorkshopSections(techCan,8),[]);
const orders=[{id:1,reference:'OS-SIM-1',client_id:5,state:'open',assigned_to:12,equipment:{id:4,identifier:'BOMBA-SIM',description:'Bomba sintética',serial:'SER-SIM'}},{id:2,reference:'OS-SIM-2',client_id:5,state:'records_verified',assigned_to:12,equipment:{id:4,identifier:'BOMBA-SIM',description:'Bomba sintética',serial:'SER-SIM'}}];
assert.deepEqual(workshop.filterWorkshopOrders(orders,'sintetica','open').map(o=>o.id),[1]);
assert.equal(workshop.filterWorkshopOrders(orders,'SER-SIM').length,2);
assert.deepEqual(workshop.visibleWorkshopEquipment(orders).map(e=>e.orders),[[1,2]]);
for(const section of workshop.workshopSections.filter(s=>s.pending)){
const pending=renderToStaticMarkup(React.createElement(WorkshopMenu,{section:section.id,context,can:workshopCan,api:()=>{},navigate:()=>{}}));
assert(pending.includes('Capacidade ainda não disponível'));assert(!pending.includes('Saldo inicial'));
}
const invoice=model.invoiceRow({id:1,series_code:'TEST-A',number:4,issued_at:'2026-10-29T10:00:00Z'},{snapshot:{issued_local:'2026-10-29 12:00:00',document:{client:{name:'João sintético',data:{nuit:'SIM-001'}},data:{currency:'MZN'}},calculation:{total_document_proposed:'11600.00'}},document_lines:[],corrections:[]});
const draft=model.draftRow({id:2,name:'Proposta sintética',version:3,data:{client_name:'Cliente sintético',currency:'USD'},base_amount:'10.000000000001',actor_id:9});
assert.equal(invoice.total,'11600.00');assert.equal(invoice.balance,null);assert.equal(invoice.dueDate,null);assert.equal(draft.total,null);assert.equal(draft.base,'10.000000000001');
const rows=[invoice,draft];
for(const [filters,expected] of [[{search:'joao'},['invoice:1']],[{search:'SIM-001'},['invoice:1']],[{currency:'USD'},['draft:2']],[{from:'2026-10-01'},['invoice:1']],[{to:'2026-10-01'},[]],[{author:'9'},['draft:2']],[{series:'TEST-A',type:'draft'},[]]])assert.deepEqual(model.filterBillingRows(rows,filters).map(r=>r.key),expected);
const calls=[];
const loaded=await model.loadBillingRows(async path=>{calls.push(path);if(path==='/api/a/billing_drafts')return {records:[]};throw new Error('fixture outage');},can,7);
assert.equal(loaded.sources.find(s=>s.label==='Facturas').error,'fixture outage');assert.equal(loaded.sources.find(s=>s.label==='Rascunhos').error,undefined);
assert.deepEqual(await model.loadBillingRows(()=>{throw Error('Unauthorised fetch');},()=>false,7),{rows:[],sources:[]});
const html=renderToStaticMarkup(React.createElement(BillingDocumentList,{rows,filters:{search:'',type:'',currency:'',series:'',state:'',author:'',from:'',to:''},onFilter:()=>{},onClear:()=>{},onOpen:()=>{}}));
for(const text of ['11600.00','10.000000000001','Consultar percurso financeiro','Por determinar','SIM-001','MZN','USD','Limpar filtros'])assert(html.includes(text));
for(const section of model.billingSections.filter(s=>s.pending)){
const pending=renderToStaticMarkup(React.createElement(Workspace,{section:section.id,context,can,api:()=>{},navigate:()=>{}}));
assert(pending.includes('Capacidade ainda não disponível'));assert(!pending.includes('Confirmar emissão'));
}
// Actual existing isolated SGF host: use the established evaluation entry, no credentials.
console.log('Checking existing isolated HTTP sources…');
const cookies=new Map();
async function request(path,method='GET',body){
const r=await fetch('http://127.0.0.1:8002'+path,{signal:AbortSignal.timeout(15000),method,redirect:'manual',headers:{Connection:'close',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),...(body?{'Content-Type':'application/x-www-form-urlencoded'}:{})},...(body?{body:new URLSearchParams(body)}:{})});
for(const c of r.headers.getSetCookie()){const pair=c.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
const text=await r.text();return {status:r.status,text};
}
const landing=await request('/avaliar');assert.equal(landing.status,200);const csrf=landing.text.match(/name="_token" value="([^"]+)"/)?.[1];assert(csrf);
assert.equal((await request('/avaliar/entrar','POST',{role:'preparador',_token:csrf})).status,302);
const session=JSON.parse((await request('/api/session')).text),unit=session.context.unit.id;
const actualCan=(p,u=null)=>session.context.grants.some(g=>g.permission===p&&g.unit_id===u);
const actual=await model.loadBillingRows(async path=>{const r=await request(path);assert.equal(r.status,200,path);return JSON.parse(r.text);},actualCan,unit);
assert(actual.sources.every(s=>!s.error));assert(actual.rows.length>0);
for(const row of actual.rows.filter(r=>r.type==='invoice'))assert.equal(row.total,row.detail.snapshot.calculation.total_document_proposed);
const actualWorkshop=JSON.parse((await request('/api/workshop')).text);assert.equal(actualWorkshop.blocked,false);
assert(actualWorkshop.orders.every(o=>o.equipment&&o.reference&&o.state));
const logout=await fetch('http://127.0.0.1:8002/api/logout',{method:'POST',headers:{Connection:'close',Accept:'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':session.csrf}});assert.equal(logout.status,200);
await fs.writeFile('docs/proofs/billing-menu.json',JSON.stringify({checked_at:new Date().toISOString(),navigation_sections:7,workshop_sections:12,permission_and_unit_checks:true,technician_financial_menu_hidden:true,equipment_identity_preserved:true,filters_checked:true,source_outage_distinguished:true,exact_document_values_preserved:true,actual_host:'http://127.0.0.1:8002',source_counts:actual.sources.map(s=>({source:s.label,count:s.rows.length})),visible_workshop_orders:actualWorkshop.orders.length,business_commands:0,visual_review:false,publication:false},null,2)+'\n');
console.log('Billing menu passed: seven sections, scoped navigation, exact values, combined filters, unknown balances/dates, unavailable sources and actual isolated host reads. Not a browser visual review.');
}finally{await vite.close();}
+22 -1
View File
@@ -5,8 +5,9 @@ import {renderToStaticMarkup} from 'react-dom/server';
import {createServer} from 'vite';
const server=await createServer({server:{middlewareMode:true},appType:'custom'});
try {
const {navigationGroups}=await server.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
const {navigationGroups,navigationTopics,default:Navigation}=await server.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
const {default:Home}=await server.ssrLoadModule('/resources/js/WorkspaceHome.jsx');
const {default:PurchasingHome}=await server.ssrLoadModule('/resources/js/PurchasingHome.jsx');
const context={company:{name:'Empresa sintética'},unit:{id:7,name:'Unidade sintética'}};
const check=(grants)=>{
const can=(permission,unit=null)=>grants.some(g=>g[0]===permission&&g[1]===unit);
@@ -24,5 +25,25 @@ try {
const reader=check([['company.view',null],['industrial_budgets.view',7]]);
assert(!reader.html.includes('<h2>A validar</h2>'));assert(!reader.html.includes('<h2>A preparar e acompanhar</h2>'));
assert(reader.html.includes('não agrega nem conta tarefas pendentes'));
const allGroups=navigationGroups(()=>true,7),topics=navigationTopics(allGroups);
assert.deepEqual(topics.map(t=>t.label),['Início','Comercial','Compras','Stock','Oficinas','Financeiro','Configuração']);
const before=allGroups.flatMap(g=>g.items.map(i=>i.id)).sort(),after=topics.flatMap(t=>t.sections.flatMap(g=>g.items.map(i=>i.id))).sort();
assert.deepEqual(after,before);assert.equal(new Set(after).size,after.length);
const navHtml=renderToStaticMarkup(React.createElement(Navigation,{groups:allGroups,page:'billing_drafts',navigate:()=>{},user:{name:'Avaliador sintético',email:'consulta@example.test'},busy:false,logout:()=>{}}));
assert(navHtml.includes('workspace-topbar'));assert(!navHtml.includes('grouped-sidebar'));assert(!navHtml.includes('navigation-drawer'));
for(const topic of topics.filter(t=>t.id!=='start'))assert(navHtml.includes(`aria-controls="topic-${topic.id}"`));
assert(navHtml.includes('aria-expanded="false"'));assert(navHtml.includes('topic-item-active'));assert(navHtml.includes('aria-current="page"'));
const foreignOnly=navigationTopics(otherUnit.groups);assert(!foreignOnly.flatMap(t=>t.sections).some(g=>g.id==='costs'));
const future=navigationTopics([{id:'future_module',label:'Novo assunto',items:[{id:'future_page',label:'Destino autorizado'}]}]);assert.equal(future[0].sections[0].items[0].id,'future_page');
assert.equal(navigationTopics(noAccess.groups).length,1);
const companyOnly=check([['company.view',null]]),purchaseTopic=navigationTopics(companyOnly.groups).find(t=>t.id==='purchasing');
assert(purchaseTopic);assert.deepEqual(purchaseTopic.sections[0].items.map(i=>i.id),['purchasing_home']);
assert(navigationTopics(companyOnly.groups).some(t=>t.id==='stock'));
const purchaseHtml=renderToStaticMarkup(React.createElement(PurchasingHome,{context,can:p=>p==='company.view',navigate:()=>{}}));
assert(purchaseHtml.includes('Área de Compras'));assert(purchaseHtml.includes('ainda não apresenta percursos operacionais'));
assert(!purchaseHtml.includes('<button'));
const budgetOnly=check([['company.view',null],['purchasing_budget.view',7]]);
assert(budgetOnly.groups.find(g=>g.id==='purchasing').items.some(i=>i.id==='purchasing_budget'));
assert(!budgetOnly.groups.find(g=>g.id==='purchasing').items.some(i=>i.id==='supplier_payables'));
console.log('Home/navigation render checks passed: preparer, reviewer, reader, other unit and absent permissions. No database or browser used.');
} finally {await server.close();}
+46
View File
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
const base = 'http://127.0.0.1:8002';
const cookies = new Map();
let csrf = '', context = '';
async function request(path, method = 'GET', body) {
const response = await fetch(base + path, {method, redirect: 'manual', headers: {
Accept: 'application/json', Cookie: [...cookies].map(([k,v]) => `${k}=${v}`).join('; '),
'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-TOKEN': csrf, 'X-SGF-Context': context,
}, ...(body ? {body: new URLSearchParams(body)} : {})});
for (const cookie of response.headers.getSetCookie()) {
const pair = cookie.split(';', 1)[0], index = pair.indexOf('=');
cookies.set(pair.slice(0, index), pair.slice(index + 1));
}
return {status: response.status, text: await response.text()};
}
async function enter() {
const page = await request('/avaliar');
assert.equal(page.status, 200);
csrf = page.text.match(/name="_token" value="([^"]+)"/)[1];
assert.equal((await request('/avaliar/entrar', 'POST', {role: 'proprietario', _token: csrf})).status, 302);
const session = JSON.parse((await request('/api/session')).text);
assert.equal(session.user.email, 'proprietario-avaliacao@example.test');
assert(session.context);
csrf = session.csrf; context = session.context.token;
return session;
}
const first = await enter();
const modules = ['a/clients','a/services','a/service_drafts','a/billing_drafts','workshop','workshop-billing',
'workshop-corrections','direct-costs','industrial-costs','industrial-budgets','service-invoices',
'service-receivables','service-fiscal','catalogue-accounting','document-preview/model','accesses'];
for (const module of modules) assert.equal((await request('/api/' + module)).status, 200, module);
for (const permission of ['workshop.record', 'workshop.verify', 'direct_costs.prepare', 'direct_costs.validate',
'industrial_costs.prepare', 'industrial_costs.validate', 'catalogue_accounting.propose', 'catalogue_accounting.approve']) {
assert(first.context.grants.some(g => g.permission === permission), permission);
}
assert.equal((await request('/avaliar/entrar', 'POST', {role: 'proprietario', user_id: '1', _token: csrf})).status, 422);
const second = await enter();
assert.equal(second.user.id, first.user.id);
assert.deepEqual(second.context.grants, first.context.grants, 'Re-entry must not duplicate grants');
await fs.writeFile('docs/proofs/local-owner-access.json', JSON.stringify({checked_at: new Date().toISOString(),
instance: base, synthetic_owner: true, consulted_modules: modules, grants: second.context.grants.length,
repeated_entry_stable: true, arbitrary_user_refused: true, business_commands: 0, real_accounts_changed: false,
process_separation_rules_removed: false, ssh_used: false, visual_review: false}, null, 2) + '\n');
console.log('Local owner entry, 16 module queries, combined permissions and repeat entry passed.');
+38
View File
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
const reports=[];
for (const [host,port,page] of [['compras',8005,'/obrigacoes-fornecedores'],['sgf',8006,'/supplier-payables']]) {
const cookies=new Map();
async function request(path,method='GET',body) {
const r=await fetch(`http://127.0.0.1:${port}${path}`, {method,redirect:'manual',headers:{Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),...(body?{'Content-Type':'application/x-www-form-urlencoded'}:{})},...(body?{body:new URLSearchParams(body)}:{})});
for(const c of r.headers.getSetCookie()) {const pair=c.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
return {status:r.status,text:await r.text(),location:r.headers.get('location')};
}
const token=text=>{const m=text.match(/name="_token" value="([^"]+)"/);assert(m,'CSRF token present');return m[1];};
const entry=await request('/demo');assert.equal(entry.status,200);assert(!entry.text.includes('type="password"'));
assert.equal((await request('/demo/entrar','POST',{role:'consulta'})).status,419);
assert.equal((await request('/demo/entrar','POST',{role:'admin',_token:token(entry.text)})).status,302);
let baseline;
for(const role of ['preparacao','validacao','consulta']) {
const landing=await request('/demo');
const login=await request('/demo/entrar','POST',{role,_token:token(landing.text)});
assert.equal(login.status,302,host+' '+role+' '+login.text.slice(0,150));assert(login.location.endsWith(page));
const ui=await request(page);assert.equal(ui.status,200,host+' authorised page');assert(ui.text.includes('Trocar função ou sair da demonstração'));
assert.equal(ui.text.includes('data-action="prepare"'),role==='preparacao');
if(role==='consulta')assert(!ui.text.includes('<form data-action='));
const data=JSON.parse((await request('/api/supplier-payables')).text);
assert.equal(data.documents[0].total_minor,1160000);
const counts=[data.proposals.length,data.obligations.length,data.events.length];
if(baseline)assert.deepEqual(counts,baseline);else baseline=counts;
if(host==='sgf'){const session=JSON.parse((await request('/api/session')).text);assert.equal(session.user.email,role+'-obrigacoes@example.test');assert(session.context);assert.equal((await request('/')).status,200);}
}
const landing=await request('/demo');assert.equal((await request('/demo/sair','POST',{_token:token(landing.text)})).status,302);
assert.notEqual((await request(page)).status,200);
const again=await request('/demo');assert.equal((await request('/demo/entrar','POST',{role:'consulta',_token:token(again.text)})).status,302);
const recovered=JSON.parse((await request('/api/supplier-payables')).text);assert.deepEqual([recovered.proposals.length,recovered.obligations.length,recovered.events.length],baseline);
await request('/demo/sair','POST',{_token:token((await request('/demo')).text)});
reports.push({host,passed:true,password_required:false,roles:['preparacao','validacao','consulta'],csrf_enforced:true,arbitrary_role_refused:true,context_selected:host==='sgf',history_preserved:true,financial_commands_executed:false});
}
await fs.writeFile('docs/proofs/payables-demo-entry.json',JSON.stringify({checked_at:new Date().toISOString(),hosts:reports,visual_review:false,public_deployment:false},null,2)+'\n');
console.log('Password-free synthetic entry passed in both compositions; no financial commands executed.');
+71
View File
@@ -0,0 +1,71 @@
<?php
// Registered exclusively by the isolated payables router, never by production routes.
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
$isSgf = str_starts_with($s['DB_DATABASE'], 'payable_sgf_');
$destination = $isSgf ? '/supplier-payables' : '/obrigacoes-fornecedores';
$guardDemo = static function (): void {
$db = DB::connection();
abort_unless(app()->environment('payable-testing') && $db->getDriverName() === 'pgsql'
&& $db->getConfig('host') === '127.0.0.1'
&& preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D', $db->getDatabaseName())
&& $db->selectOne('select current_database() as d')->d === $db->getDatabaseName(), 403);
};
Route::middleware('web')->group(function () use ($guardDemo, $isSgf, $destination) {
Route::get('/demo', function (Request $r) use ($guardDemo, $isSgf, $destination) {
$guardDemo();
$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>';
}
$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>';
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>';
}
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);
// 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();
abort_unless($grants->count() === 1, 403);
$g = $grants->first();
if ($isSgf) {
app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id, $g->tenant_id, $g->company_id, $g->unit_id);
} else {
abort_unless((int) $u->tenant_id === (int) $g->tenant_id && (int) $g->company_id === 0 && (int) $g->unit_id === 0, 403);
}
Auth::logout();
$r->session()->invalidate();
$r->session()->regenerateToken();
Auth::login($u, false);
$r->session()->regenerate();
$r->session()->put('payables_demo_entry', true);
if ($isSgf) {
$r->session()->put('auth_version', (int) $u->auth_version);
$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);
})->middleware('throttle:30,1');
Route::post('/demo/sair', function (Request $r) use ($guardDemo) {
$guardDemo();
Auth::logout();
$r->session()->invalidate();
$r->session()->regenerateToken();
return redirect('/demo');
});
});
+8 -1
View File
@@ -6,4 +6,11 @@ if(!preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D',$s['DB_D
foreach($s as $k=>$v){putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
$public=realpath($s['HOST_ROOT'].'/public');$uri=rawurldecode(parse_url($_SERVER['REQUEST_URI'],PHP_URL_PATH)?:'/');$file=realpath($public.$uri);
if($uri!=='/'&&$file&&str_starts_with($file,$public.DIRECTORY_SEPARATOR)&&is_file($file))return false;
require $public.'/index.php';
// Password-free entry belongs only to this isolated demonstration host.
define('LARAVEL_START', microtime(true));
require $s['HOST_ROOT'].'/vendor/autoload.php';
$app = require $s['HOST_ROOT'].'/bootstrap/app.php';
$app->booted(function () use ($s) {
require __DIR__.'/payables-demo-entry.php';
});
$app->handleRequest(\Illuminate\Http\Request::capture());
+81
View File
@@ -0,0 +1,81 @@
<?php
// Evaluation entry exists only on the local synthetic product host.
use App\Models\User;
use App\Shared\Testing\TestingDatabaseGuard;
use App\Shared\Context\CompanyContext;
use App\Modules\Identity\Services\Audit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\DB;
$demoManifest = static function (Request $request): array {
abort_unless(app()->environment('browser-testing')
&& in_array($request->ip(), ['127.0.0.1', '::1'], true)
&& in_array($request->getHost(), ['127.0.0.1', 'localhost', '::1'], true), 404);
TestingDatabaseGuard::assertSafe();
$manifest = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'), true, 64, JSON_THROW_ON_ERROR);
abort_unless(($manifest['product_demo']['version'] ?? '') === 'SGF-PRODUCT-DEMO-v1'
&& ($manifest['product_demo']['synthetic_only'] ?? false) === true, 404);
return $manifest;
};
Route::get('/avaliar', function (Request $request) use ($demoManifest) {
$demoManifest($request);
$csrf = htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8');
$html = '<!doctype html><html lang="pt"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Explorar SGF Tempo</title><style>body{font:18px system-ui;background:#f4f6f5;color:#183d35;margin:0}main{max-width:960px;margin:auto;padding:30px}section{background:white;padding:24px;border-radius:12px;margin:20px 0}button,a{font:inherit}button{padding:14px;border:0;border-radius:8px;background:#125a4b;color:white;cursor:pointer}form{display:inline-block;margin:8px}a{color:#125a4b}</style><main><h1>Explorar SGF Tempo</h1><p>Entrada de avaliação local, sem palavra-passe. Podes trocar de função sempre que quiseres, sem pedir concessões.</p><section><h2>Aplicação integrada</h2><p>Clientes, serviços, documentação, facturas de teste, recebimentos simulados, oficina, custos e orçamento industrial.</p>';
foreach (['proprietario' => 'Entrar como proprietário — todas as áreas desta instalação', 'preparador' => 'Preparar e gerir', 'validador' => 'Rever e validar', 'tecnico' => 'Registar trabalho de oficina', 'verificador' => 'Verificar trabalho de oficina'] as $role => $label) {
$html .= '<form method="post" action="/avaliar/entrar"><input type="hidden" name="_token" value="'.$csrf.'"><input type="hidden" name="role" value="'.$role.'"><button>'.$label.'</button></form>';
}
$html .= '</section><section><h2>Percursos em instalações separadas</h2><p><a href="http://127.0.0.1:8003">Controlo orçamental de Compras</a></p><p><a href="http://127.0.0.1:8006/demo">Obrigações a fornecedores no SGF</a></p><p><a href="http://127.0.0.1:8005/demo">Compras autónomo</a></p></section><p>Dados sintéticos persistentes e partilhados. A troca de função conserva os exemplos e o histórico. Esta entrada não concede administração da instalação real nem elimina as regras dos processos.</p></main></html>';
return response($html)->header('Cache-Control', 'no-store')->header('X-Robots-Tag', 'noindex, nofollow');
});
Route::post('/avaliar/entrar', function (Request $request) use ($demoManifest) {
$manifest = $demoManifest($request);
$data = $request->validate(['role' => 'required|in:proprietario,preparador,validador,tecnico,verificador']);
abort_if(array_diff(array_keys($request->all()), ['role', '_token']) !== [], 422);
if ($data['role'] === 'proprietario') {
$user = DB::transaction(function () use ($manifest) {
// Serialise provisioning, including first entry. Never touch customer accounts.
DB::select('select pg_advisory_xact_lock(?)', [80202910]);
$owner = User::firstOrCreate(['email' => 'proprietario-avaliacao@example.test'],
['name' => 'Proprietário — avaliação local', 'password' => bin2hex(random_bytes(32))]);
$actors = array_values($manifest['product_demo']['actors']);
$memberships = DB::table('company_memberships')->whereIn('user_id', $actors)->get(['tenant_id', 'company_id'])->unique('company_id');
$added = 0;
foreach ($memberships as $membership) {
DB::table('company_memberships')->insertOrIgnore(['user_id' => $owner->id, 'tenant_id' => $membership->tenant_id, 'company_id' => $membership->company_id]);
}
$grants = DB::table('permission_grants')->whereIn('user_id', $actors)->get(['tenant_id', 'company_id', 'permission', 'unit_id']);
foreach (['view', 'propose', 'approve'] as $action) {
$grants->push((object) ['tenant_id' => $manifest['tenant'], 'company_id' => $manifest['company'], 'permission' => 'catalogue_accounting.'.$action, 'unit_id' => null]);
}
foreach ($grants as $grant) {
$scope = ['user_id' => $owner->id, 'tenant_id' => $grant->tenant_id, 'company_id' => $grant->company_id, 'permission' => $grant->permission, 'unit_id' => $grant->unit_id];
if (!DB::table('permission_grants')->where($scope)->exists()) {
DB::table('permission_grants')->insert($scope + ['created_at' => now(), 'updated_at' => now()]);
$added++;
}
}
if ($added) {
Audit::record('demo.owner_access_prepared', $owner->id, $manifest['tenant'], $manifest['company'], $manifest['unit'], details: ['synthetic' => true, 'grants_added' => $added]);
}
return $owner;
});
} else {
$user = User::findOrFail($manifest['product_demo']['actors'][$data['role']]);
}
abort_unless(str_ends_with($user->email, '@example.test'), 403);
app(CompanyContext::class)->selectFor($user->id, $manifest['tenant'], $manifest['company'], $manifest['unit']);
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
Auth::login($user, false);
$request->session()->regenerate();
$request->session()->put('auth_version', (int) $user->auth_version);
$request->session()->put('company_context', ['tenant_id' => (int) $manifest['tenant'], 'company_id' => (int) $manifest['company'], 'unit_id' => (int) $manifest['unit'], 'token' => bin2hex(random_bytes(24))]);
Audit::record('demo.entered', $user->id, $manifest['tenant'], $manifest['company'], $manifest['unit'], details: ['role' => $data['role'], 'synthetic' => true]);
return redirect('/');
})->middleware('throttle:30,1');
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {createServer} from 'vite';
const server=await createServer({server:{middlewareMode:true},appType:'custom'});
try{
const {stockSections,visibleStockSections,availableStockReports}=await server.ssrLoadModule('/resources/js/stock-menu.js');
const {default:Workspace}=await server.ssrLoadModule('/resources/js/StockMenuWorkspace.jsx');
const {navigationGroups}=await server.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
const {default:Home}=await server.ssrLoadModule('/resources/js/WorkspaceHome.jsx');
const companyCan=p=>p==='company.view';
const context={token:'synthetic',company:{id:1,name:'Empresa sintética A'},unit:{id:7,name:'Unidade A'}};
assert.deepEqual(stockSections.map(s=>s.label),['Painel','Existências','Entradas','Saídas','Transferências','Devoluções','Relatórios']);
assert.deepEqual(visibleStockSections(()=>false),[]);
assert.deepEqual(visibleStockSections(p=>p==='workshop.view'),[]);
assert.deepEqual(availableStockReports().map(r=>r.id),['balances','movements','minimum']);
assert(!availableStockReports({transit:true,returns:true,lots:true,inventory:true,history:true},false).some(r=>r.restricted));
assert(availableStockReports({},true).some(r=>r.id==='valuation'));
assert(!availableStockReports({transit:false,lots:false}).some(r=>r.feature));
const groups=navigationGroups(companyCan,7),stock=groups.find(g=>g.id==='stock');
assert.equal(stock.status,'Em preparação');assert(stock.items.every(i=>i.informational));
const home=renderToStaticMarkup(React.createElement(Home,{context,can:companyCan,groups,navigate:()=>{}}));assert(home.includes('shortcut-status'));
for(const section of stockSections){
const html=renderToStaticMarkup(React.createElement(Workspace,{section:section.id,context,can:companyCan,navigate:()=>{}}));
assert(html.includes('fonte de stock ainda não ligada'));assert(html.includes('Empresa sintética A'));assert(html.includes('Submenus de stock'));
assert(!html.includes('<form'));assert(!html.includes('type="submit"'));assert(!html.includes('value="0"'));assert(!html.includes('0.000'));assert(!html.includes('http://127.0.0.1:8005'));
if(section.id==='stock_reports'){assert(html.includes('Consulta indisponível'));assert(!html.includes('value="valuation"'));assert(!html.includes('value="expiry"'));}
const other=renderToStaticMarkup(React.createElement(Workspace,{section:section.id,context:{...context,company:{id:2,name:'Empresa sintética B'}},can:companyCan,navigate:()=>{}}));
assert(other.includes('Empresa sintética B'));assert(!other.includes('Empresa sintética A'));
}
const denied=renderToStaticMarkup(React.createElement(Workspace,{section:'stock_balances',context,can:()=>false,navigate:()=>{}}));
assert(!denied.includes('Submenus de stock'));
await fs.writeFile('docs/proofs/stock-menu.json',JSON.stringify({checked_at:new Date().toISOString(),sections:7,kind:'informational navigation and source-unavailable states',not_operational_stock:true,no_fabricated_balances:true,conditional_reports_hidden:true,cost_values_not_loaded:true,no_business_forms:true,no_company_or_workshop_permission_inferred_as_stock_permission:true,company_context_rendering:true,backend_calls:0,business_commands:0,visual_review:false,publication:false},null,2)+'\n');
console.log('Stock menu checks passed: seven entries, source-unavailable states, no fabricated balances, no business forms, conditional reports/costs hidden and company context preserved. Operational integration remains pending.');
}finally{await server.close();}