This commit is contained in:
2026-10-29 18:43:29 +02:00
parent f77900e198
commit 78e89a51d2
75 changed files with 2881 additions and 14 deletions
+10 -4
View File
@@ -1,4 +1,4 @@
param([ValidateSet('start','stop')][string]$Action = 'start', [ValidateRange(1024,65535)][int]$Port = 8001, [switch]$ResumeIndustrialBudget)
param([ValidateSet('start','stop')][string]$Action = 'start', [ValidateRange(1024,65535)][int]$Port = 8001, [switch]$ResumeIndustrialBudget, [switch]$ProductDemo)
$ErrorActionPreference = 'Stop'
$sgfProject = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
if ($sgfProject -ne 'C:\laragon\www\sgf-tempo') { throw 'Unexpected project path.' }
@@ -17,13 +17,19 @@ if ($Action -eq 'stop') {
Remove-Item -LiteralPath $recordFile
}
$fixtureFile = 'C:\laragon\data\sgf-tempo\secrets\browser-fixture.json'
if (Test-Path -LiteralPath $fixtureFile) { Remove-Item -LiteralPath $fixtureFile }
Write-Output 'Browser proof server stopped. Synthetic data remains only in sgf_tempo_test until the next test run.'
if (Test-Path -LiteralPath $fixtureFile) {
$fixture = Get-Content -LiteralPath $fixtureFile -Raw | ConvertFrom-Json
if (!$fixture.product_demo) { Remove-Item -LiteralPath $fixtureFile }
}
Write-Output 'Browser proof server stopped. Synthetic data remains in sgf_tempo_test; integrated demo manifests are preserved.'
exit 0
}
if (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) { throw "Port $Port occupied; no process was stopped." }
if (Test-Path -LiteralPath $recordFile) { throw 'Browser proof record exists; stop that proof first.' }
if ($ResumeIndustrialBudget) {
if ($ProductDemo -and $ResumeIndustrialBudget) { throw 'Choose one demonstration mode.' }
if ($ProductDemo) {
& $sgfPhp scripts/product-demo.php --env=testing
} elseif ($ResumeIndustrialBudget) {
& $sgfPhp scripts/industrial-budget-manual-prepare.php --env=testing
} else {
& $sgfPhp scripts/prepare-browser-fixture.php --env=testing
+50
View File
@@ -0,0 +1,50 @@
<?php
// Provision a new dedicated QA database/role, using the existing local maintenance tool connection.
// No reads/migrations of business databases; no schema changes to existing SGF demonstrations.
$sgf=realpath(__DIR__.'/..'); $compras=realpath($sgf.'/../compras');
require $compras.'/vendor/autoload.php';
foreach (['APP_ENV'=>'budget-coordinator','DB_CONNECTION'=>'pgsql','CACHE_STORE'=>'array','SESSION_DRIVER'=>'array'] as $k=>$v) {putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
$app=require $compras.'/bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
$proof=in_array('--proof',$argv,true)||in_array('--cleanup-proof',$argv,true);
$settingsFile='C:/laragon/data/sgf-tempo/secrets/'.($proof?'budget-proof.json':'budget-demo.json');
if(is_file($settingsFile)&&!in_array('--cleanup-proof',$argv,true)) {echo "Existing budget demonstration retained; no database, user or password replaced.\n";exit;}
$source=DB::connection('pgsql')->getConfig();
if(($source['host']??'')!=='127.0.0.1'||($source['driver']??'')!=='pgsql')throw new LogicException('Local maintenance connection required.');
config(['database.connections.budget_admin'=>[...$source,'database'=>'postgres','url'=>null]]);
$admin=DB::connection('budget_admin')->getPdo();
if(in_array('--cleanup-proof',$argv,true)) {
if(!is_file($settingsFile)){echo "No disposable budget proof to clean.\n";exit;}
$settings=json_decode(file_get_contents($settingsFile),true,32,JSON_THROW_ON_ERROR);
$database=$settings['DB_DATABASE'];$role=$settings['DB_USERNAME'];
if(!preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D',$database,$match)||$role!=='budget_demo_'.$match[1]||$settings['APP_ENV']!=='budget-testing')throw new LogicException('Unsafe proof cleanup target.');
$teamFile='C:/laragon/data/sgf-tempo/secrets/budget-demo.json';
if(is_file($teamFile)&&json_decode(file_get_contents($teamFile),true,32,JSON_THROW_ON_ERROR)['DB_DATABASE']===$database)throw new LogicException('Team demonstration must never be a proof cleanup target.');
$q=$admin->prepare('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname=? AND pid<>pg_backend_pid()');$q->execute([$database]);
$admin->exec('DROP DATABASE "'.$database.'"');$admin->exec('DROP ROLE "'.$role.'"');
unlink($settingsFile);$access='C:/laragon/data/sgf-tempo/secrets/budget-proof-access.json';if(is_file($access))unlink($access);
echo "Disposable budget proof database/role removed; team demonstration preserved.\n";exit;
}
$suffix=bin2hex(random_bytes(6));$database='budget_core_sgf_'.$suffix;$role='budget_demo_'.$suffix;$password=bin2hex(random_bytes(32));
$roleCreated=false;$dbCreated=false;
try {
$admin->exec('CREATE ROLE "'.$role.'" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '.$admin->quote($password));$roleCreated=true;
$admin->exec('CREATE DATABASE "'.$database.'" OWNER "'.$role.'"');$dbCreated=true;
$settings=['APP_ENV'=>'budget-testing','APP_DEBUG'=>'false','APP_KEY'=>'base64:'.base64_encode(random_bytes(32)),'APP_URL'=>'http://127.0.0.1:8003','DB_CONNECTION'=>'pgsql','DB_HOST'=>'127.0.0.1','DB_PORT'=>(string)$source['port'],'DB_DATABASE'=>$database,'DB_USERNAME'=>$role,'DB_PASSWORD'=>$password,'DB_URL'=>'','SESSION_DRIVER'=>'file','SESSION_COOKIE'=>'sgf_budget_demo','SESSION_FILES'=>$sgf.'/storage/framework/budget-demo/sessions','CACHE_STORE'=>'file','CACHE_FILE_PATH'=>$sgf.'/storage/framework/budget-demo/cache','CACHE_LOCK_PATH'=>$sgf.'/storage/framework/budget-demo/locks','QUEUE_CONNECTION'=>'sync','MAIL_MAILER'=>'array','BCRYPT_ROUNDS'=>'4'];
$settings['BUDGET_DEMO_ACCESS_FILE']='C:/laragon/data/sgf-tempo/secrets/'.($proof?'budget-proof-access.json':'budget-demo-access.json');
if($proof){
$settings['SESSION_COOKIE']='sgf_budget_proof';
foreach(['SESSION_FILES','CACHE_FILE_PATH','CACHE_LOCK_PATH'] as $k)$settings[$k]=str_replace('/budget-demo/','/budget-proof/',$settings[$k]);
}
foreach(['SESSION_FILES','CACHE_FILE_PATH','CACHE_LOCK_PATH'] as $k)if(!is_dir($settings[$k])&&!mkdir($settings[$k],0700,true))throw new RuntimeException('Cannot prepare isolated runtime directory.');
$process=new Process(['C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',$sgf.'/scripts/budget-demo-seed.php'],$sgf,$settings);$process->setTimeout(60);$process->run();
if(!$process->isSuccessful())throw new RuntimeException('Synthetic preparation failed: '.$process->getOutput().$process->getErrorOutput());
if(file_put_contents($settingsFile,json_encode($settings,JSON_THROW_ON_ERROR),LOCK_EX)===false)throw new RuntimeException('Cannot save isolated server settings.');
echo "Dedicated synthetic budget database and restricted QA role created. Four function accounts stored only in the protected local access file.\n";
} catch (\Throwable $e) {
if($dbCreated){$q=$admin->prepare('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname=? AND pid<>pg_backend_pid()');$q->execute([$database]);$admin->exec('DROP DATABASE "'.$database.'"');}
if($roleCreated)$admin->exec('DROP ROLE "'.$role.'"');
throw $e;
}
+11
View File
@@ -0,0 +1,11 @@
<?php
// Local-only test host configuration; credentials never exposed through HTTP or source control.
$settingsFile=getenv('BUDGET_DEMO_SETTINGS_FILE') ?: 'C:/laragon/data/sgf-tempo/secrets/budget-demo.json';
if (!in_array($settingsFile,['C:/laragon/data/sgf-tempo/secrets/budget-demo.json','C:/laragon/data/sgf-tempo/secrets/budget-proof.json'],true)) {http_response_code(503);exit('Invalid test settings target.');}
$settings=json_decode(file_get_contents($settingsFile),true,32,JSON_THROW_ON_ERROR);
if (!preg_match('/^budget_core_sgf_[a-f0-9]{12}$/D',$settings['DB_DATABASE']??'') || ($settings['DB_HOST']??'')!=='127.0.0.1' || ($settings['APP_ENV']??'')!=='budget-testing') {
http_response_code(503); exit('Invalid isolated demonstration configuration.');
}
foreach ($settings as $key=>$value) { putenv($key.'='.$value); $_ENV[$key]=$_SERVER[$key]=$value; }
require __DIR__.'/router.php';
+52
View File
@@ -0,0 +1,52 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Modules\Purchasing\BudgetAdapter::guard();
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
if (Schema::hasTable('users')) throw new RuntimeException('Fresh isolated database required; nothing reset.');
require __DIR__.'/../packages/budget-control/tests/sgf-schema.php';
require __DIR__.'/../packages/budget-control/tests/host-support.php';
Schema::create('cb_ui_commands', function (Blueprint $t) {
$t->id(); foreach (['tenant_id','company_id','unit_id','actor_id'] as $c) $t->unsignedBigInteger($c);
$t->uuid('command_key'); $t->string('action'); $t->string('request_hash',64); $t->jsonb('result'); $t->timestamp('created_at');
$t->unique(['tenant_id','company_id','unit_id','actor_id','command_key']);
});
Schema::create('cb_consumption_sources', function (Blueprint $t) {
$t->id(); foreach (['tenant_id','company_id','unit_id','origin_id','created_by'] as $c) $t->unsignedBigInteger($c);
$t->string('reference',100); $t->bigInteger('total_minor'); $t->bigInteger('subtotal_minor'); $t->timestamp('created_at');
$t->unique(['tenant_id','company_id','unit_id','reference']);
$t->foreign('origin_id')->references('id')->on('cb_origins')->restrictOnDelete();
});
$f = DB::transaction(function () {
$f = budgetQaFixture('sgf');
DB::table('tenants')->where('id',$f['tenant'])->update(['name'=>'Cliente sintético — orçamento']);
DB::table('companies')->where('id',$f['company'])->update(['name'=>'Empresa Horizonte — Orçamento sintético']);
DB::table('units')->where('id',$f['unit'])->update(['name'=>'Unidade Central — Orçamento sintético']);
DB::table('cb_centres')->where('id',$f['centre'])->update(['code'=>'ORC-DEMO']);
DB::table('cb_origins')->where('id',$f['origin'])->update(['reference'=>'SIM-COMPRA-001']);
$f['release_origin'] = DB::table('cb_origins')->insertGetId($f['scope']+['cost_center_id'=>$f['centre'],'required_kind'=>'operating','reference'=>'SIM-COMPRA-LIBERTAR']);
$f['second_centre'] = DB::table('cb_centres')->insertGetId($f['scope']+['code'=>'ORC-OUTRO']);
DB::table('permission_grants')->where('user_id',$f['actor'])->whereIn('permission',['purchasing_budget.record','purchasing_budget.consume'])->delete();
$accounts=[];
foreach (['preparacao'=>['Preparação orçamental','preparacao-orcamento@example.test',$f['actor']], 'validacao'=>['Validação orçamental','validacao-orcamento@example.test',$f['validator']], 'operacao'=>['Operação orçamental','operacao-orcamento@example.test',null], 'consulta'=>['Consulta orçamental','consulta-orcamento@example.test',null]] as $role=>[$name,$email,$id]) {
$password=bin2hex(random_bytes(20));
$user=$id?\App\Models\User::findOrFail($id):new \App\Models\User;
$user->fill(['name'=>$name,'email'=>$email,'password'=>$password]); $user->save();
if (!$id) {
DB::table('company_memberships')->insert(['user_id'=>$user->id,'tenant_id'=>$f['tenant'],'company_id'=>$f['company']]);
foreach (['company.view'=>null,'unit.view'=>$f['unit']] as $p=>$unit) DB::table('permission_grants')->insert(['user_id'=>$user->id,'tenant_id'=>$f['tenant'],'company_id'=>$f['company'],'unit_id'=>$unit,'permission'=>$p,'created_at'=>now(),'updated_at'=>now()]);
foreach ($role==='operacao'?['view','record','consume']:['view'] as $p) DB::table('permission_grants')->insert($f['scope']+['user_id'=>$user->id,'permission'=>'purchasing_budget.'.$p,'created_at'=>now(),'updated_at'=>now()]);
}
$accounts[$role]=['id'=>$user->id,'email'=>$email,'password'=>$password];
}
// Another company is deliberately present without membership; the reserved unit has no grant.
return $f+['accounts'=>$accounts];
});
$file=getenv('BUDGET_DEMO_ACCESS_FILE') ?: 'C:/laragon/data/sgf-tempo/secrets/budget-demo-access.json';
if (!in_array($file,['C:/laragon/data/sgf-tempo/secrets/budget-demo-access.json','C:/laragon/data/sgf-tempo/secrets/budget-proof-access.json'],true)) throw new RuntimeException('Unexpected access manifest target.');
if (file_put_contents($file,json_encode($f,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR),LOCK_EX)===false)throw new RuntimeException('Cannot save protected synthetic access manifest.');
echo "Budget demonstration prepared with four distinct synthetic functions; no budget decisions or movements prefilled.\n";
+13
View File
@@ -0,0 +1,13 @@
<?php
$settings=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/budget-demo.json'),true,32,JSON_THROW_ON_ERROR);
foreach($settings as $k=>$v){putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Modules\Purchasing\BudgetAdapter::guard();
$db=\Illuminate\Support\Facades\DB::connection();
$role=$db->selectOne('select rolsuper,rolcreatedb,rolcreaterole from pg_roles where rolname=current_user');
if($role->rolsuper||$role->rolcreatedb||$role->rolcreaterole)throw new RuntimeException('Restricted test role required.');
$tables=[];foreach(['users','cb_budgets','cb_budget_entries','cb_budget_consumptions','cb_ui_commands'] as $t)$tables[$t]=$db->table($t)->count();
$report=['version'=>'SGF-BUDGET-WORKSPACE-SIM-v1','url'=>'http://127.0.0.1:8003','isolated_database'=>$db->getDatabaseName(),'restricted_role'=>true,'synthetic_only'=>true,'counts'=>$tables,'core_sha256'=>hash_file('sha256',__DIR__.'/../packages/budget-control/src/BudgetControl.php'),'checked_at'=>gmdate('c')];
file_put_contents(__DIR__.'/../docs/proofs/budget-demo-availability.json',json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL);
echo json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
+28
View File
@@ -0,0 +1,28 @@
param([ValidateSet('start','stop','status')][string]$Action='status')
$ErrorActionPreference='Stop'
$budgetProject=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
if($budgetProject -ne 'C:\laragon\www\sgf-tempo'){throw 'Unexpected project path.'}
$budgetRecord='C:\laragon\data\sgf-tempo\run\budget-demo.json'
$budgetPhp='C:\laragon\tools\sgf-tempo\php-8.4.27\php.exe'
if($Action -eq 'status') { Get-NetTCPConnection -LocalPort 8003 -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress,LocalPort,OwningProcess; exit }
if($Action -eq 'stop') {
if(Test-Path -LiteralPath $budgetRecord){
$budgetProcessRecord=Get-Content -Raw -LiteralPath $budgetRecord | ConvertFrom-Json
$budgetProcess=Get-Process -Id $budgetProcessRecord.pid -ErrorAction SilentlyContinue
if($budgetProcess){if($budgetProcess.Path -ne $budgetPhp -or $budgetProcess.StartTime.ToUniversalTime().ToString('o') -ne $budgetProcessRecord.started){throw 'Process identity mismatch.'}; Stop-Process -Id $budgetProcess.Id}
Remove-Item -LiteralPath $budgetRecord
}
Write-Output 'Budget server stopped. Demonstration data, role and credentials retained.';exit
}
if(Get-NetTCPConnection -LocalPort 8003 -State Listen -ErrorAction SilentlyContinue){throw 'Port 8003 occupied; no process stopped.'}
if(Test-Path -LiteralPath $budgetRecord){throw 'Budget server record exists. Inspect/stop the owned server first.'}
$budgetSecrets='C:\laragon\data\sgf-tempo\secrets'
$budgetAccount=[Security.Principal.WindowsIdentity]::GetCurrent().Name
New-Item -ItemType Directory -Path $budgetSecrets -Force | Out-Null
& 'C:\Users\macbook\.config\investmoz\php-8.5.11\php.exe' (Join-Path $PSScriptRoot 'budget-demo-provision.php')
if($LASTEXITCODE){throw 'Budget demonstration preparation failed.'}
foreach($budgetSecret in @('budget-demo.json','budget-demo-access.json')){& icacls.exe (Join-Path $budgetSecrets $budgetSecret) /inheritance:r /grant:r "${budgetAccount}:F" 'SYSTEM:F' | Out-Null}
New-Item -ItemType Directory -Path (Join-Path $budgetProject 'storage\framework\budget-demo\sessions'),(Join-Path $budgetProject 'storage\framework\budget-demo\cache'),(Join-Path $budgetProject 'storage\framework\budget-demo\locks') -Force | Out-Null
$budgetProcess=Start-Process -FilePath $budgetPhp -ArgumentList @('-S','127.0.0.1:8003','-t','public','scripts/budget-demo-router.php') -WorkingDirectory $budgetProject -WindowStyle Hidden -PassThru -RedirectStandardOutput 'C:\laragon\data\sgf-tempo\run\budget-demo.stdout.log' -RedirectStandardError 'C:\laragon\data\sgf-tempo\run\budget-demo.stderr.log'
@{pid=$budgetProcess.Id;started=$budgetProcess.StartTime.ToUniversalTime().ToString('o');port=8003}|ConvertTo-Json|Set-Content -LiteralPath $budgetRecord -Encoding UTF8
Write-Output 'Synthetic budget demonstration: http://127.0.0.1:8003. Existing SGF demonstrations preserved.'
+58
View File
@@ -0,0 +1,58 @@
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
import {randomUUID,createHash} from 'node:crypto';
const fixture=JSON.parse(await fs.readFile('C:/laragon/data/sgf-tempo/secrets/budget-proof-access.json','utf8'));
const base='http://127.0.0.1:8004', checks=[];
function client(){const cookies=new Map();let csrf='',context='';return {
async request(path,method='GET',body,options={}){
const r=await fetch(base+path,{method,headers:{Accept:'application/json','Content-Type':'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':options.noCsrf?'':csrf,'X-SGF-Context':options.stale?'stale-context':context,...(options.key?{'Idempotency-Key':options.key}:{})},...(body===undefined?{}:{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 data=await r.json().catch(()=>({}));if(data.csrf)csrf=data.csrf;if(data.context?.token)context=data.context.token;
return {status:r.status,data};
},
async login(role){assert.equal((await this.request('/api/session')).status,200);const a=fixture.accounts[role];assert.equal((await this.request('/api/login','POST',{email:a.email,password:a.password})).status,200);assert.equal((await this.request('/api/context','POST',{tenant_id:fixture.tenant,company_id:fixture.company,unit_id:fixture.unit})).status,200);},
async send(action,input,key=randomUUID(),expected=200){const r=await this.request('/api/purchasing-budget/'+action,'POST',input,{key});assert.equal(r.status,expected,action+': '+(r.data.message??''));return r.data;},
async list(){const r=await this.request('/api/purchasing-budget');assert.equal(r.status,200);return r.data;}
};}
const anonymous=client();assert.equal((await anonymous.request('/api/purchasing-budget')).status,401);
const p=client(),v=client(),o=client(),q=client();
await p.login('preparacao');await v.login('validacao');await o.login('operacao');await q.login('consulta');
const initial=await q.list();assert.equal(initial.budgets.length,0);checks.push('four_real_sessions_and_empty_initial_demo');
const allocation={cost_center_id:fixture.centre,year:initial.current_year,kind:'operating',includes_tax:true,amount:'1000.00',reason:'Dotação exclusivamente sintética para prova HTTP'};
const key=randomUUID(),created=await p.send('create',allocation,key);assert.equal((await p.send('create',allocation,key)).replayed,true);
await p.send('create',{...allocation,amount:'1001.00'},key,409);await q.send('create',allocation,randomUUID(),403);
await p.send('approve',{budget_id:created.id},randomUUID(),403);await v.send('approve',{budget_id:created.id});
const snapshots=[];
for(const [kind,amount,reserved,committed] of [['reserve','300.00',30000,0],['award','350.00',35000,0],['commit','350.00',0,35000]]){
const input={origin_id:fixture.origin,kind,total:amount,subtotal:amount},k=randomUUID();
await o.send('movement',input,k);assert.equal((await o.send('movement',input,k)).replayed,true);
const data=await q.list(),b=data.budgets.find(x=>x.id===created.id);assert.equal(b.balance.reserved_minor,reserved);assert.equal(b.balance.committed_minor,committed);snapshots.push({step:kind,balance:b.balance});
if(kind==='award')await o.send('movement',{origin_id:fixture.release_origin,kind:'reserve',total:'700.00',subtotal:'700.00'},randomUUID(),422);
}
await o.send('movement',{origin_id:fixture.origin,kind:'release',total:'0.00',subtotal:'0.00'},randomUUID(),409);
await o.send('movement',{origin_id:fixture.origin,kind:'reserve',total:'301.00',subtotal:'301.00'},randomUUID(),422);
await o.send('consume',{origin_id:fixture.origin,reference:'SIM-CONSUMO-HTTP',total:'100.001',subtotal:'100.00'},randomUUID(),422);
const consumption={origin_id:fixture.origin,reference:'SIM-CONSUMO-HTTP',total:'100.00',subtotal:'100.00'};
await o.send('consume',{...consumption,total:'400.00',subtotal:'400.00'},randomUUID(),422);
assert.equal((await q.list()).consumption_sources.length,0,'Failed consumption left a synthetic reference behind.');
await o.send('consume',consumption);await o.send('consume',consumption);
let data=await q.list(),b=data.budgets.find(x=>x.id===created.id);
assert.equal(data.entries.length,4);assert.equal(data.consumptions.length,1);assert.equal(data.consumption_sources.length,1);assert.equal(b.balance.committed_minor,25000);assert.equal(b.balance.consumed_minor,10000);assert.equal(b.balance.available_minor,65000);snapshots.push({step:'consume',balance:b.balance});
await o.send('consume',{...consumption,total:'101.00'},randomUUID(),409);
const proposed=await p.send('reinforcement',{budget_id:created.id,amount:'200.00',reference:'SIM-REFORCO-HTTP',reason:'Reforço sintético HTTP'});
assert.equal((await q.list()).budgets[0].balance.available_minor,65000);
await p.send('review',{reinforcement_id:proposed.id,decision:'approved',reason:'Tentativa sem direito'},randomUUID(),403);
await v.send('review',{reinforcement_id:proposed.id,decision:'approved',reason:'Revisão sintética distinta por HTTP'});
data=await q.list();assert.equal(data.budgets[0].balance.available_minor,85000);snapshots.push({step:'reinforcement',balance:data.budgets[0].balance});
for(const kind of ['reserve','release'])await o.send('movement',{origin_id:fixture.release_origin,kind,total:'200.00',subtotal:'200.00'});
assert.equal((await q.list()).budgets[0].balance.available_minor,85000);
await o.send('movement',{origin_id:fixture.release_origin,kind:'commit',total:'200.00',subtotal:'200.00'},randomUUID(),409);
await o.send('movement',{origin_id:999999,kind:'reserve',total:'1.00',subtotal:'1.00'},randomUUID(),403);
assert.equal((await o.request('/api/purchasing-budget/movement','POST',{origin_id:fixture.origin,kind:'reserve',total:'300.00',subtotal:'300.00'},{key:randomUUID(),stale:true})).status,409);
assert.equal((await o.request('/api/purchasing-budget/movement','POST',{}, {key:randomUUID(),noCsrf:true})).status,419);
assert.equal((await q.request('/api/context','POST',{tenant_id:fixture.tenant,company_id:fixture.company,unit_id:fixture.unit-1})).status,403);
checks.push('allocation_distinct_approval','same_command_recovery','changed_key_conflict','reservation_award_commit_equivalent','insufficient_balance','committed_release_refused','decimal_precision_rejected','failed_consumption_rollback_and_recovery','single_consumption_per_reference','pending_reinforcement_no_effect','distinct_reinforcement_review','release_once_no_reopening','foreign_origin_refused','stale_context_refused','csrf_enabled','reserved_unit_refused','reader_cannot_write');
for(const c of [p,v,o,q])assert.equal((await c.request('/api/logout','POST')).status,200);
const source=await fs.readFile('packages/budget-control/src/BudgetControl.php');
await fs.writeFile('docs/proofs/budget-workspace-http.json',JSON.stringify({version:'SGF-BUDGET-WORKSPACE-SIM-v1',checked_at:new Date().toISOString(),passed:true,core_sha256:createHash('sha256').update(source).digest('hex'),checks,snapshots,visual_review:false,team_demo_changed:false},null,2)+'\n');
console.log('Budget workspace HTTP proof passed: four functions, exact balances, traceable effects, no duplicate consumption, isolation and CSRF. Team demo unchanged.');
+25
View File
@@ -0,0 +1,25 @@
$ErrorActionPreference='Stop'
$budgetRoot=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
if($budgetRoot -ne 'C:\laragon\www\sgf-tempo'){throw 'Unexpected project path.'}
if(Get-NetTCPConnection -LocalPort 8004 -State Listen -ErrorAction SilentlyContinue){throw 'Proof port occupied; no process stopped.'}
$budgetProvisionPhp='C:\Users\macbook\.config\investmoz\php-8.5.11\php.exe'
$budgetWebPhp='C:\laragon\tools\sgf-tempo\php-8.4.27\php.exe'
$budgetProofProcess=$null
try {
& $budgetProvisionPhp (Join-Path $PSScriptRoot 'budget-demo-provision.php') --proof
if($LASTEXITCODE){throw 'Isolated proof preparation failed.'}
$budgetAccount=[Security.Principal.WindowsIdentity]::GetCurrent().Name
foreach($budgetFile in @('budget-proof.json','budget-proof-access.json')){& icacls.exe "C:\laragon\data\sgf-tempo\secrets\$budgetFile" /inheritance:r /grant:r "${budgetAccount}:F" 'SYSTEM:F' | Out-Null}
$env:BUDGET_DEMO_SETTINGS_FILE='C:/laragon/data/sgf-tempo/secrets/budget-proof.json'
$budgetProofProcess=Start-Process -FilePath $budgetWebPhp -ArgumentList @('-S','127.0.0.1:8004','-t','public','scripts/budget-demo-router.php') -WorkingDirectory $budgetRoot -WindowStyle Hidden -PassThru -RedirectStandardOutput 'C:\laragon\data\sgf-tempo\run\budget-proof.stdout.log' -RedirectStandardError 'C:\laragon\data\sgf-tempo\run\budget-proof.stderr.log'
Remove-Item Env:BUDGET_DEMO_SETTINGS_FILE
$budgetReady=$false
for($budgetAttempt=0;$budgetAttempt -lt 30;$budgetAttempt++) { if(Get-NetTCPConnection -LocalPort 8004 -State Listen -ErrorAction SilentlyContinue){$budgetReady=$true;break}; Start-Sleep -Milliseconds 100 }
if(!$budgetReady){throw 'Proof server did not start.'}
Push-Location $budgetRoot
try { & 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/budget-workspace-http-proof.mjs; if($LASTEXITCODE){throw 'HTTP budget workspace checks failed.'} } finally {Pop-Location}
} finally {
Remove-Item Env:BUDGET_DEMO_SETTINGS_FILE -ErrorAction SilentlyContinue
if($budgetProofProcess -and !$budgetProofProcess.HasExited){Stop-Process -Id $budgetProofProcess.Id}
& $budgetProvisionPhp (Join-Path $PSScriptRoot 'budget-demo-provision.php') --cleanup-proof
}
+16
View File
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import {createServer} from 'vite';
const server=await createServer({server:{middlewareMode:true},appType:'custom'});
try {
const {budgetMoney}=await server.ssrLoadModule('/resources/js/PurchasingBudgetWorkspace.jsx');
const {navigationGroups}=await server.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
assert.equal(budgetMoney(100000),'1 000.00 MZN');
assert.equal(budgetMoney(-35000),'-350.00 MZN');
assert.equal(budgetMoney(65000),'650.00 MZN');assert.equal(budgetMoney(85000),'850.00 MZN');
assert.equal(budgetMoney(99999999999999),'999 999 999 999.99 MZN');
assert.equal(budgetMoney(null),'Indisponível');
const visible=grants=>navigationGroups((p,u)=>grants.some(g=>g[0]===p&&g[1]===u),7).flatMap(g=>g.items).some(i=>i.id==='purchasing_budget');
assert(visible([['purchasing_budget.view',7]]));
assert(!visible([['purchasing_budget.view',8]]));assert(!visible([]));
console.log('Budget UI checks passed: exact cent formatting, signed deltas, unknown values and navigation scoped to the permitted unit. Not a browser visual review.');
} finally { await server.close(); }
+39
View File
@@ -0,0 +1,39 @@
param([ValidateSet('start','stop','status','proof')][string]$Action='status',[switch]$HistoryOnly)
$ErrorActionPreference='Stop'
$payRoot=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
if($payRoot -ne 'C:\laragon\www\sgf-tempo'){throw 'Unexpected project path'}
$payPrivate='C:/laragon/data/sgf-tempo/secrets/'
$payRun='C:\laragon\data\sgf-tempo\run'
$payPhp='C:\Users\macbook\.config\investmoz\php-8.5.11\php.exe'
$payProof=$Action -eq 'proof';$payTag=if($payProof){'proof'}else{'demo'}
if($Action -eq 'status'){Get-NetTCPConnection -LocalPort 8005,8006 -State Listen -ErrorAction SilentlyContinue|Select-Object LocalAddress,LocalPort,OwningProcess;exit}
if($Action -eq 'stop'){
foreach($payMode in @('compras','sgf')){$payRecord=Join-Path $payRun "payables-$payMode.json";if(Test-Path -LiteralPath $payRecord){$paySaved=Get-Content -Raw -LiteralPath $payRecord|ConvertFrom-Json;$payProcess=Get-Process -Id $paySaved.pid -ErrorAction SilentlyContinue;if($payProcess){if($payProcess.Path -ne $paySaved.executable -or $payProcess.StartTime.ToUniversalTime().ToString('o') -ne $paySaved.started){throw 'Process mismatch'};Stop-Process -Id $payProcess.Id};Remove-Item -LiteralPath $payRecord}}
Write-Output 'Payables demo servers stopped; synthetic scenarios retained.';exit
}
$payPorts=if($payProof){@(8007,8008)}else{@(8005,8006)}
foreach($payPort in $payPorts){if(Get-NetTCPConnection -LocalPort $payPort -State Listen -ErrorAction SilentlyContinue){throw "Port $payPort occupied; no process stopped."}}
$payProcesses=@()
try{
if($payProof){& $payPhp (Join-Path $PSScriptRoot 'payables-provision.php') --proof}else{& $payPhp (Join-Path $PSScriptRoot 'payables-provision.php')}
if($LASTEXITCODE){throw 'Payables preparation failed'}
$payOwner=[Security.Principal.WindowsIdentity]::GetCurrent().Name
foreach($payFile in @(Get-ChildItem -LiteralPath $payPrivate -Filter "payables-$payTag-*.json" -File)){& icacls.exe $payFile.FullName /inheritance:r /grant:r "${payOwner}:F" 'SYSTEM:F'|Out-Null}
$payIndex=0
foreach($payMode in @('compras','sgf')){
$payHost=if($payMode -eq 'compras'){'C:\laragon\www\compras'}else{$payRoot}
$payWebPhp=if($payMode -eq 'compras'){$payPhp}else{'C:\laragon\tools\sgf-tempo\php-8.4.27\php.exe'}
$payPort=$payPorts[$payIndex];$payIndex++
$env:PAYABLE_SETTINGS_FILE=$payPrivate+"payables-$payTag-$payMode.json"
$payProcess=Start-Process -FilePath $payWebPhp -ArgumentList @('-S',"127.0.0.1:$payPort",'-t','public',"$payRoot\scripts\payables-router.php") -WorkingDirectory $payHost -WindowStyle Hidden -PassThru -RedirectStandardOutput "$payRun\payables-$payTag-$payMode.stdout.log" -RedirectStandardError "$payRun\payables-$payTag-$payMode.stderr.log"
$payProcesses+=$payProcess
Remove-Item Env:PAYABLE_SETTINGS_FILE
if(!$payProof){@{pid=$payProcess.Id;started=$payProcess.StartTime.ToUniversalTime().ToString('o');executable=$payWebPhp;port=$payPort}|ConvertTo-Json|Set-Content -LiteralPath "$payRun\payables-$payMode.json" -Encoding UTF8}
$payReady=$false;for($payAttempt=0;$payAttempt -lt 30;$payAttempt++){if(Get-NetTCPConnection -LocalPort $payPort -State Listen -ErrorAction SilentlyContinue){$payReady=$true;break};Start-Sleep -Milliseconds 100};if(!$payReady){throw 'Payables server failed to start'}
}
if($payProof){Push-Location $payRoot;try{if($HistoryOnly){& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/payables-http-proof.mjs --history-only}else{& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/payables-http-proof.mjs};if($LASTEXITCODE){throw 'Payables HTTP proof failed'}}finally{Pop-Location}}
else{Write-Output 'Synthetic payables: Compras http://127.0.0.1:8005/obrigacoes-fornecedores ; SGF http://127.0.0.1:8006. Budget demonstration preserved.'}
}finally{
Remove-Item Env:PAYABLE_SETTINGS_FILE -ErrorAction SilentlyContinue
if($payProof){foreach($payProcess in $payProcesses){if(!$payProcess.HasExited){Stop-Process -Id $payProcess.Id}};& $payPhp (Join-Path $PSScriptRoot 'payables-provision.php') --cleanup-proof}
}
+69
View File
@@ -0,0 +1,69 @@
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
import {randomUUID,createHash} from 'node:crypto';
import {execFile} from 'node:child_process';
import {promisify} from 'node:util';
const run=promisify(execFile), reports=[];
const historyOnly=process.argv.includes('--history-only');
async function probe(mode,job,input={}){
// Use spawned stdin for payloads; credentials stay in protected settings read by PHP.
const {spawn}=await import('node:child_process');
return await new Promise((resolve,reject)=>{const p=spawn(mode==='compras'?'C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe':'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['scripts/payables-probe.php',mode,job],{cwd:process.cwd(),windowsHide:true});let out='',err='';p.stdout.on('data',d=>out+=d);p.stderr.on('data',d=>err+=d);p.on('error',reject);p.on('close',code=>{try{if(code)throw new Error('QA probe failed: '+out+err);resolve(JSON.parse(out));}catch(e){reject(e);}});p.stdin.end(JSON.stringify(input));});
}
for(const mode of ['compras','sgf']){
const fixture=JSON.parse(await fs.readFile(`C:/laragon/data/sgf-tempo/secrets/payables-proof-${mode}-access.json`,'utf8'));
const base=mode==='compras'?'http://127.0.0.1:8007':'http://127.0.0.1:8008';
function client(){let csrf='',context='';const cookies=new Map();return {
async request(path,method='GET',body,opts={}){
const r=await fetch(base+path,{method,headers:{Accept:'application/json','Content-Type':'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':opts.noCsrf?'':csrf,'X-XSRF-TOKEN':opts.noCsrf?'':decodeURIComponent(cookies.get('XSRF-TOKEN')??''),'X-SGF-Context':opts.stale?'stale':context,...(opts.key?{'Idempotency-Key':opts.key}:{})},...(body===undefined?{}:{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,text};
},
async login(role){await this.request(mode==='compras'?'/login':'/api/session');const a=fixture.accounts[role];const r=await this.request(mode==='compras'?'/login':'/api/login','POST',{email:a.email,password:a.password});assert.equal(r.status,200,mode+' login');if(mode==='sgf')assert.equal((await this.request('/api/context','POST',{tenant_id:fixture.scope.tenant_id,company_id:fixture.scope.company_id,unit_id:fixture.scope.unit_id})).status,200);},
async list(){const r=await this.request('/api/supplier-payables');assert.equal(r.status,200,mode+' listing '+r.text.slice(0,180));return r.data;},
async send(action,id,key=randomUUID(),expected=200){const r=await this.request('/api/supplier-payables/'+action,'POST',{id,reason:'Prova sintética de '+action},{key});assert.equal(r.status,expected,mode+' '+action+' '+r.text.slice(0,180));return r.data;}
};}
const anon=client();assert.equal((await anon.request('/api/supplier-payables')).status,401);
const p=client(),v=client(),q=client();await p.login('preparacao');await v.login('validacao');await q.login('consulta');
let list=await q.list();assert.equal(list.obligations.length,0);const doc=list.documents[0];assert.equal(doc.subtotal_minor,1000000);assert.equal(doc.tax_minor,160000);assert.equal(doc.total_minor,1160000);assert.equal(doc.status,'validated');assert(doc.validation_audit_id);
const before=await probe(mode,'inspect');
if(historyOnly){
await probe(mode,'history');const existing=await probe(mode,'inspect');
const pending=await p.send('prepare',fixture.source_id);assert.equal(pending.state,'pending_source');assert(pending.missing.some(x=>x.includes('pagamentos')));
assert.equal((await v.send('validate',pending.proposal_id)).state,'conflict');
const refused=await probe(mode,'inspect');assert.equal(refused.counts.obligations,0);assert.deepEqual(refused.domain_hashes,existing.domain_hashes);if(mode==='compras')assert.equal(refused.legacy_balance,1130000);
reports.push({host:mode,passed:true,core_sha256:refused.core_sha256,source_revision:doc.revision,recognised_obligations:0,checks:['previous_settlement_blocks_recognition','history_unchanged','no_full_unpaid_import'],visual_review:false});continue;
}
const preparerPage=await p.request(mode==='compras'?'/obrigacoes-fornecedores':'/supplier-payables');assert.equal(preparerPage.status,200);assert(preparerPage.text.includes('data-action="prepare"'));
const readerPage=await q.request(mode==='compras'?'/obrigacoes-fornecedores':'/supplier-payables');assert(!readerPage.text.includes('<form data-action='));
const racePrepare=await probe(mode,'race',{role:'preparacao',action:'prepare',id:fixture.source_id});assert.equal(racePrepare.waits,2);assert.deepEqual(racePrepare.results.map(r=>r.state),['prepared','prepared']);
const key=randomUUID(),prepared=await p.send('prepare',fixture.source_id,key);assert.equal(prepared.state,'prepared');assert.equal((await p.send('prepare',fixture.source_id,key)).replayed,true);
const conflictKey=await p.request('/api/supplier-payables/prepare','POST',{id:fixture.source_id,reason:'Conteúdo divergente'},{key});assert.equal(conflictKey.status,409);
await q.send('prepare',fixture.source_id,randomUUID(),403);
await probe(mode,'grant',{role:'preparacao',action:'validate',enabled:true});await p.send('validate',prepared.proposal_id,randomUUID(),409);await probe(mode,'grant',{role:'preparacao',action:'validate',enabled:false});
await probe(mode,'source',{override:{status:'pending'}});const incomplete=await p.send('prepare',fixture.source_id);assert.equal(incomplete.state,'pending_source');assert(incomplete.missing.length>0);assert.equal((await q.list()).obligations.length,0);
assert.equal((await v.send('validate',prepared.proposal_id)).state,'conflict');
await probe(mode,'source');const ready=await p.send('prepare',fixture.source_id);assert.equal(ready.state,'prepared');assert.notEqual(ready.proposal_id,incomplete.proposal_id);
const revokedRace=await probe(mode,'race',{role:'validacao',action:'validate',id:ready.proposal_id,revoke:true});assert.deepEqual(revokedRace.results.map(r=>r.status),[403,403]);assert.equal((await q.list()).obligations.length,0);await probe(mode,'grant',{role:'validacao',action:'validate',enabled:true});
await probe(mode,'fault',{enabled:true});const failedKey=randomUUID();await v.send('validate',ready.proposal_id,failedKey,500);assert.equal((await probe(mode,'inspect')).counts.obligations,0);await probe(mode,'fault',{enabled:false});
const raceValidate=await probe(mode,'race',{role:'validacao',action:'validate',id:ready.proposal_id});assert.deepEqual(raceValidate.results.map(r=>r.state),['recognised','recognised']);
const recognised=await v.send('validate',ready.proposal_id,failedKey);assert.equal(recognised.state,'recognised');assert.equal((await v.send('validate',ready.proposal_id,failedKey)).replayed,true);
assert.equal((await p.send('prepare',fixture.source_id)).obligation_id,recognised.obligation_id);
list=await q.list();assert.equal(list.obligations.length,1);const o=list.obligations[0];assert.equal(o.principal_minor,1160000);assert.equal(o.balance_minor,1160000);assert.equal(o.due_on,null);assert.equal(o.days_overdue,null);assert.equal(o.due_status,'unknown');
const future=await probe(mode,'future');assert.equal(future[0].days_overdue,null);assert.equal(future[0].due_on,null);assert.equal(future[0].principal_minor,1160000);
const page=await q.request(mode==='compras'?'/obrigacoes-fornecedores':'/supplier-payables');assert(page.text.includes('11 600.00 MZN'));assert(page.text.includes('Principal reconhecido — vencimento pendente'));assert(page.text.includes('Dias de atraso: sem valor'));
const foreign=await probe(mode,'foreign');assert.equal(foreign.refused,true);
const foreignActor=await probe(mode,'foreign_actor');assert.equal(foreignActor.refused,true);
await probe(mode,'source',{override:{total_minor:1160001}});assert.equal((await p.send('prepare',fixture.source_id)).state,'conflict');assert.equal((await q.list()).obligations[0].principal_minor,1160000);assert.equal((await q.list()).obligations[0].balance_minor,null);await probe(mode,'source');
const after=await probe(mode,'inspect');assert.deepEqual(after.domain_hashes,before.domain_hashes);assert.equal(after.counts.obligations,1);assert.equal(after.recognised_events,1);assert.equal(after.counts.events,after.counts.outbox);if(mode==='compras')assert.equal(after.legacy_balance,1160000);
await probe(mode,'history');const historyBefore=await probe(mode,'inspect');
const historyAttempt=await p.send('prepare',fixture.source_id);assert.equal(historyAttempt.state,'conflict');
const historyAfter=await probe(mode,'inspect');assert.equal(historyAfter.counts.obligations,1);assert.equal(historyAfter.obligations[0].balance_minor,null);assert.equal(historyAfter.obligations[0].principal_minor,1160000);
assert.deepEqual(historyBefore.domain_hashes,historyAfter.domain_hashes);if(mode==='compras')assert.equal(historyAfter.legacy_balance_blocked,409);
const denied=await v.request('/api/supplier-payables/validate','POST',{id:ready.proposal_id,reason:'Falta CSRF'},{key:randomUUID(),noCsrf:true});assert.equal(denied.status,419);
if(mode==='sgf'){assert.equal((await v.request('/api/supplier-payables/validate','POST',{id:ready.proposal_id,reason:'Contexto antigo'},{key:randomUUID(),stale:true})).status,409);assert.equal((await q.request('/api/context','POST',{tenant_id:fixture.scope.tenant_id,company_id:fixture.scope.company_id,unit_id:fixture.scope.unit_id-1})).status,403);}
reports.push({host:mode,passed:true,core_sha256:after.core_sha256,source_revision:doc.revision,principal_minor:o.principal_minor,due_on:null,days_overdue:null,races:[racePrepare,revokedRace,raceValidate],checks:['genuine_conferred_invoice','no_duplicate_new_keys','conflicting_payload','distinct_person','incomplete_source_pending','source_revision_conflict','revocation_while_waiting','event_fault_atomic_rollback','retry_after_failure','unknown_due_even_future','foreign_scope_refused','no_stock_budget_payment_effect','existing_settlement_history_refused_and_preserved','durable_events_outbox','read_only_ui','csrf_active'],visual_review:false});
}
assert.equal(reports[0].core_sha256,reports[1].core_sha256);assert.equal(reports[0].source_revision,reports[1].source_revision);
await fs.writeFile(historyOnly?'docs/proofs/payables-history.json':'docs/proofs/payables-http.json',JSON.stringify({version:'COMMON-PAYABLE-SOURCE-SIM-v1',passed:true,checked_at:new Date().toISOString(),team_budget_preserved:true,hosts:reports},null,2)+'\n');
console.log('Two payables hosts passed: one 11600 MZN obligation, unknown due date, concurrency, faults/recovery, versions, isolation and unchanged budget/stock/payments.');
+83
View File
@@ -0,0 +1,83 @@
<?php
// Fault/adversarial checks are permitted ONLY in the disposable proof databases.
$mode=$argv[1]??'';$job=$argv[2]??'';$private='C:/laragon/data/sgf-tempo/secrets/';
if(!in_array($mode,['compras','sgf'],true))throw new LogicException('Unknown proof host');
$settings=json_decode(file_get_contents($private.'payables-proof-'.$mode.'.json'),true,32,JSON_THROW_ON_ERROR);
$team=$private.'payables-demo-'.$mode.'.json';if(is_file($team)&&json_decode(file_get_contents($team),true)['DB_DATABASE']===$settings['DB_DATABASE'])throw new LogicException('Team demo protected');
foreach($settings as $k=>$v){putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
require $settings['HOST_ROOT'].'/vendor/autoload.php';$app=require $settings['HOST_ROOT'].'/bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
$db=DB::connection();if(!preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D',$db->getDatabaseName())||$db->selectOne('select current_database() as d')->d!==$settings['DB_DATABASE'])throw new LogicException('Unsafe probe target');
$f=json_decode(file_get_contents($settings['PAYABLE_ACCESS_FILE']),true,32,JSON_THROW_ON_ERROR);
$input=json_decode(stream_get_contents(STDIN)?:'{}',true,64,JSON_THROW_ON_ERROR);
$class=$mode==='compras'?\App\Services\SupplierPayables::class:\App\Modules\Purchasing\SupplierPayables::class;
$select=function($role)use($mode,$f){$u=\App\Models\User::findOrFail($f['accounts'][$role]['id']);auth()->guard()->setUser($u);if($mode==='sgf')app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id,$f['scope']['tenant_id'],$f['scope']['company_id'],$f['scope']['unit_id']);return $u;};
if($job==='inspect'){
$counts=[];foreach(['sources','proposals','obligations','events','outbox','commands'] as $t)$counts[$t]=$db->table('pa_'.$t)->count();
$domain=[];if($mode==='compras')foreach(['invoices','invoice_items','budget_entries','budget_consumptions','stock_movements','external_payments'] as $t)$domain[$t]=hash('sha256',\Tempo\Finance\Payables\Payables::canonical($db->table($t)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all()));
$u=$select('consulta');$d=app($class)->listing($u);$out=['counts'=>$counts,'domain_hashes'=>$domain,'core_sha256'=>hash_file('sha256',__DIR__.'/../packages/payables/src/Payables.php'),'recognised_events'=>$db->table('pa_events')->where('kind','payable.recognised')->count(),'obligations'=>$d['obligations']];
if($mode==='compras'){try{$out['legacy_balance']=app(\App\Services\ExternalPaymentWorkflow::class)->outstanding(\App\Models\Invoice::findOrFail($f['source_id']));}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){$out['legacy_balance_blocked']=$e->getStatusCode();}}
}elseif($job==='grant'){
$role=$input['role'];$action=$input['action'];if(!isset($f['accounts'][$role])||!in_array($action,['view','prepare','validate'],true))throw new LogicException('Invalid synthetic grant');
$match=$f['scope']+['user_id'=>$f['accounts'][$role]['id'],'action'=>$action];
if($input['enabled'])$db->table('pa_access')->insertOrIgnore($match+['person'=>'synthetic-person-'.$role]);else$db->table('pa_access')->where($match)->delete();$out=['changed'=>true];
}elseif($job==='source'){
$original=json_decode(file_get_contents($settings['PAYABLE_BUNDLE']),true,64,JSON_THROW_ON_ERROR);
$over=$input['override']??[];if(array_diff(array_keys($over),['status','total_minor','supplier_name','currency','payment_count']))throw new LogicException('Invalid adversarial fields');
$s=array_replace($original,$over);
if($mode==='compras'){
$row=$db->table('invoices')->where('id',$f['source_id'])->first();$snap=json_decode($row->snapshot,true);$snap['currency']=$s['currency'];$snap['supplier']['name']=$s['supplier_name'];
$db->table('invoices')->where('id',$f['source_id'])->update(['status'=>$s['status'],'total_minor'=>$s['total_minor'],'snapshot'=>json_encode($snap,JSON_THROW_ON_ERROR)]);
}else{$db->table('pa_imported_sources')->where('id',$f['source_id'])->update(['payload'=>\Tempo\Finance\Payables\Payables::canonical($s),'receipt_hash'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical($s))]);}
$out=['source_revision_test_only'=>true];
}elseif($job==='fault'){
if($input['enabled'])$db->unprepared("CREATE FUNCTION pa_qa_fault() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.kind='payable.recognised' THEN RAISE EXCEPTION 'PA_QA_FAULT'; END IF; RETURN NEW; END $$; CREATE TRIGGER pa_qa_fault BEFORE INSERT ON pa_events FOR EACH ROW EXECUTE FUNCTION pa_qa_fault()");
else $db->unprepared('DROP TRIGGER pa_qa_fault ON pa_events; DROP FUNCTION pa_qa_fault()');$out=['fault_configured'=>true];
}elseif($job==='worker'){
$u=$select($input['role']);$db->select('select set_config(?, ?, false)',['application_name',$input['label']]);
try{$r=app($class)->engine($u)->command($input['action'],$input['id'],'Concurrent synthetic proof',$input['key']);$out=['status'=>200,'state'=>$r['state']];}
catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){$out=['status'=>$e->getStatusCode()];}
$out['pid']=$db->selectOne('select pg_backend_pid() as p')->p;
}elseif($job==='race'){
$db->beginTransaction();$db->table($mode==='compras'?'tenants':'companies')->where('id',$mode==='compras'?$f['scope']['tenant_id']:$f['scope']['company_id'])->lockForUpdate()->firstOrFail();
$processes=[];$results=[];
try{
foreach([0,1] as $i){$p=new Process([PHP_BINARY,__FILE__,$mode,'worker'],$settings['HOST_ROOT']);$p->setInput(json_encode($input+['key'=>\Illuminate\Support\Str::uuid()->toString(),'label'=>'pa_proof_'.$i],JSON_THROW_ON_ERROR));$p->setTimeout(30);$p->start();$processes[]=$p;}
config(['database.connections.pa_monitor'=>$db->getConfig()]);$monitor=DB::connection('pa_monitor');$deadline=microtime(true)+15;$waits=0;
do{$waits=(int)$monitor->selectOne("select count(*) as n from pg_stat_activity where datname=? and application_name in ('pa_proof_0','pa_proof_1') and wait_event_type='Lock'",[$db->getDatabaseName()])->n;if($waits===2)break;foreach($processes as $p)if(!$p->isRunning())throw new RuntimeException('Worker exited: '.$p->getOutput().$p->getErrorOutput());usleep(20000);}while(microtime(true)<$deadline);
if($waits!==2)throw new RuntimeException('Concurrency overlap not observed');
if($input['revoke']??false)$db->table('pa_access')->where($f['scope'])->where(['user_id'=>$f['accounts']['validacao']['id'],'action'=>'validate'])->delete();
$db->commit();foreach($processes as $p){$p->wait();if(!$p->isSuccessful())throw new RuntimeException('Worker failed: '.$p->getOutput().$p->getErrorOutput());$results[]=json_decode($p->getOutput(),true,32,JSON_THROW_ON_ERROR);}
$out=['waits'=>$waits,'results'=>$results];
}finally{if($db->transactionLevel())$db->rollBack();foreach($processes as $p)if($p->isRunning())$p->stop();}
}elseif($job==='history'){
$u=$select('preparacao');
if($mode==='compras'){
$invoice=\App\Models\Invoice::findOrFail($f['source_id']);
$bank=\App\Models\SupplierBankDetail::query()->create(['tenant_id'=>$u->tenant_id,'supplier_id'=>$invoice->supplier_id,'created_by'=>$u->id,'revision'=>1,'bank_name'=>'Banco exclusivamente fictício','country'=>'MZ','currency'=>'MZN','beneficiary'=>'Titular de fixture histórica','account_identifier'=>'SYNTHETIC-HISTORY-NOT-A-BANK-ACCOUNT','evidence_reference'=>'Fixture histórica adversarial','reason'=>'Prova de exclusão de documentos com liquidações','created_at'=>now()]);
\App\Models\ExternalPayment::query()->create(['tenant_id'=>$u->tenant_id,'invoice_id'=>$invoice->id,'supplier_bank_detail_id'=>$bank->id,'created_by'=>$u->id,'reviewed_by'=>$f['accounts']['validacao']['id'],'status'=>'confirmed','amount_minor'=>30000,'currency'=>'MZN','paid_on'=>now()->format('Y-m-d'),'external_reference'=>'HISTORY-QA-ONLY','reference_hash'=>hash('sha256','HISTORY-QA-ONLY'),'payload_hash'=>hash('sha256','HISTORY-PAYLOAD'),'evidence_reference'=>'Historical test fixture, not executed payment','bank_snapshot'=>['synthetic_fixture'=>true],'review_reason'=>'Existing historical fact fixture','reviewed_at'=>now()]);
$rows=$db->table('external_payments')->orderBy('id')->get()->map(fn($r)=>(array)$r)->all();$out=['payment_history_hash'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical($rows))];
}else{
$r=$db->table('pa_imported_sources')->where('id',$f['source_id'])->first();$s=json_decode($r->payload,true);$s['payment_count']=1;
$db->table('pa_imported_sources')->where('id',$r->id)->update(['payload'=>\Tempo\Finance\Payables\Payables::canonical($s),'receipt_hash'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical($s))]);$out=['historical_source_projection'=>true];
}
}elseif($job==='foreign_actor'){
$now=now();$tenant=$db->table('tenants')->insertGetId(['name'=>'Outra empresa sintética','created_at'=>$now,'updated_at'=>$now]);
if($mode==='compras'){
$u=\App\Models\User::factory()->create(['tenant_id'=>$tenant,'purchasing_role'=>'finance']);$scope=['tenant_id'=>$tenant,'company_id'=>0,'unit_id'=>0];
}else{
$company=$db->table('companies')->insertGetId(['tenant_id'=>$tenant,'name'=>'Outra empresa sintética','created_at'=>$now,'updated_at'=>$now]);$unit=$db->table('units')->insertGetId(['tenant_id'=>$tenant,'company_id'=>$company,'name'=>'Outra unidade','created_at'=>$now,'updated_at'=>$now]);$scope=['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unit];
$u=\App\Models\User::create(['name'=>'Outro preparador sintético','email'=>'other-'.bin2hex(random_bytes(6)).'@example.test','password'=>bin2hex(random_bytes(20))]);
$db->table('company_memberships')->insert(['user_id'=>$u->id,'tenant_id'=>$tenant,'company_id'=>$company]);foreach(['company.view'=>null,'unit.view'=>$unit,'supplier_payables.view'=>$unit] as $permission=>$unitId)$db->table('permission_grants')->insert(['user_id'=>$u->id,'tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unitId,'permission'=>$permission,'created_at'=>$now,'updated_at'=>$now]);
}
foreach(['view','prepare'] as $action)$db->table('pa_access')->insert($scope+['user_id'=>$u->id,'person'=>'foreign-synthetic-person','action'=>$action]);
auth()->guard()->setUser($u);if($mode==='sgf')app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id,$scope['tenant_id'],$scope['company_id'],$scope['unit_id']);
try{app($class)->engine($u)->command('prepare',$f['source_id'],'Forbidden foreign document',\Illuminate\Support\Str::uuid()->toString());$out=['refused'=>false];}catch(\Illuminate\Database\Eloquent\ModelNotFoundException|\Symfony\Component\HttpKernel\Exception\HttpException){$out=['refused'=>true];}
}elseif($job==='future'){
\Illuminate\Support\Carbon::setTestNow('2030-12-31 12:00:00');$out=app($class)->listing($select('consulta'))['obligations'];
}elseif($job==='foreign'){
$u=$select('consulta');$engine=app($class)->engine($u);$foreign=new \Tempo\Finance\Payables\Payables($db,$u->id,'foreign-person',['tenant_id'=>999999,'company_id'=>999999,'unit_id'=>999999],fn()=>null,fn($id)=>$mode==='compras'?app($class)->document($u,$f['source_id']):app($class)->document($u,$f['source_id']));
try{$foreign->command('prepare',$f['source_id'],'Wrong mapped context',\Illuminate\Support\Str::uuid()->toString());$out=['refused'=>false];}catch(\DomainException){$out=['refused'=>true];}
}else throw new LogicException('Unknown probe');
echo json_encode($out,JSON_THROW_ON_ERROR).PHP_EOL;
+30
View File
@@ -0,0 +1,30 @@
<?php
$sgf=realpath(__DIR__.'/..');$compras=realpath($sgf.'/../compras');require $compras.'/vendor/autoload.php';
foreach(['APP_ENV'=>'payables-coordinator','DB_CONNECTION'=>'pgsql','CACHE_STORE'=>'array','SESSION_DRIVER'=>'array'] as $k=>$v){putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
$app=require $compras.'/bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;use Symfony\Component\Process\Process;
$proof=in_array('--proof',$argv,true)||in_array('--cleanup-proof',$argv,true);$tag=$proof?'proof':'demo';$private='C:/laragon/data/sgf-tempo/secrets/';
$source=DB::connection('pgsql')->getConfig();if($source['host']!=='127.0.0.1')throw new LogicException('Local QA maintenance only.');
config(['database.connections.pa_admin'=>[...$source,'database'=>'postgres','url'=>null]]);$admin=DB::connection('pa_admin')->getPdo();
if(in_array('--cleanup-proof',$argv,true)){
foreach(['compras','sgf'] as $mode){$path=$private.'payables-proof-'.$mode.'.json';if(!is_file($path))continue;$s=json_decode(file_get_contents($path),true,32,JSON_THROW_ON_ERROR);$db=$s['DB_DATABASE'];$role=$s['DB_USERNAME'];
if(!preg_match('/^(compras_receipts_test_|payable_sgf_)([a-f0-9]{12})$/D',$db,$m)||$role!=='payable_qa_'.$m[2])throw new LogicException('Invalid proof cleanup target.');
$team=$private.'payables-demo-'.$mode.'.json';if(is_file($team)&&json_decode(file_get_contents($team),true)['DB_DATABASE']===$db)throw new LogicException('Team database protected.');
$q=$admin->prepare('select pg_terminate_backend(pid) from pg_stat_activity where datname=? and pid<>pg_backend_pid()');$q->execute([$db]);$admin->exec('DROP DATABASE "'.$db.'"');$admin->exec('DROP ROLE "'.$role.'"');unlink($path);$a=$private.'payables-proof-'.$mode.'-access.json';if(is_file($a))unlink($a);
}$bundle=$private.'payables-proof-source.json';if(is_file($bundle))unlink($bundle);echo "Disposable payables proof databases and roles removed.\n";exit;
}
$instance='qa-compras-'.bin2hex(random_bytes(16));$bundle=$private.'payables-'.$tag.'-source.json';
foreach(['compras','sgf'] as $mode){
$path=$private.'payables-'.$tag.'-'.$mode.'.json';if(is_file($path)){echo "Existing $mode payables scenario retained.\n";continue;}
if($mode==='sgf'&&is_file($private.'payables-'.$tag.'-compras.json'))$instance=json_decode(file_get_contents($private.'payables-'.$tag.'-compras.json'),true)['PAYABLE_SOURCE_INSTANCE'];
$suffix=bin2hex(random_bytes(6));$db=($mode==='compras'?'compras_receipts_test_':'payable_sgf_').$suffix;$role='payable_qa_'.$suffix;$pw=bin2hex(random_bytes(32));
$admin->exec('CREATE ROLE "'.$role.'" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '.$admin->quote($pw));
$admin->exec('CREATE DATABASE "'.$db.'" OWNER "'.$role.'"');
$root=$mode==='compras'?$compras:$sgf;$runtime=$root.'/storage/framework/payables/'.$tag;
$env=['APP_ENV'=>$mode==='compras'?'testing':'payable-testing','APP_DEBUG'=>'false','APP_KEY'=>'base64:'.base64_encode(random_bytes(32)),'DB_CONNECTION'=>'pgsql','DB_URL'=>'','DB_HOST'=>'127.0.0.1','DB_PORT'=>(string)$source['port'],'DB_DATABASE'=>$db,'DB_USERNAME'=>$role,'DB_PASSWORD'=>$pw,'SESSION_DRIVER'=>'file','SESSION_COOKIE'=>'payables_'.$tag.'_'.$mode,'SESSION_FILES'=>$runtime.'/sessions','CACHE_STORE'=>'file','CACHE_FILE_PATH'=>$runtime.'/cache','CACHE_LOCK_PATH'=>$runtime.'/locks','QUEUE_CONNECTION'=>'sync','MAIL_MAILER'=>'array','BCRYPT_ROUNDS'=>'4','COMMON_PAYABLES_PATH'=>$sgf.'/packages/payables','PAYABLE_SOURCE_INSTANCE'=>$instance,'PAYABLE_ACCESS_FILE'=>$private.'payables-'.$tag.'-'.$mode.'-access.json','PAYABLE_BUNDLE'=>$bundle,'HOST_MODE'=>$mode,'HOST_ROOT'=>$root];
foreach(['sessions','cache','locks'] as $dir)if(!is_dir($runtime.'/'.$dir))mkdir($runtime.'/'.$dir,0700,true);
$php=$mode==='compras'?PHP_BINARY:'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe';
try{$p=new Process([$php,$sgf.'/packages/payables/tests/seed.php',$mode],$root,$env);$p->setTimeout(70);$p->run();if(!$p->isSuccessful())throw new RuntimeException('QA source preparation failed: '.$p->getOutput().$p->getErrorOutput());
$env['APP_ENV']='payable-testing';file_put_contents($path,json_encode($env,JSON_THROW_ON_ERROR),LOCK_EX);echo "Prepared isolated $mode payables scenario from conferred source.\n";
}catch(\Throwable $e){$q=$admin->prepare('select pg_terminate_backend(pid) from pg_stat_activity where datname=?');$q->execute([$db]);$admin->exec('DROP DATABASE "'.$db.'"');$admin->exec('DROP ROLE "'.$role.'"');throw $e;}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
$path=getenv('PAYABLE_SETTINGS_FILE')?:'';
if(!preg_match('~^C:/laragon/data/sgf-tempo/secrets/payables-(demo|proof)-(compras|sgf)\.json$~D',$path)){http_response_code(503);exit('Isolated host configuration required.');}
$s=json_decode(file_get_contents($path),true,32,JSON_THROW_ON_ERROR);
if(!preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D',$s['DB_DATABASE'])||$s['DB_HOST']!=='127.0.0.1'||$s['APP_ENV']!=='payable-testing'){http_response_code(503);exit('Invalid test host.');}
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';
+13
View File
@@ -0,0 +1,13 @@
<?php
if(!isset($argv[1])){
foreach(['compras','sgf'] as $mode){$php=$mode==='compras'?'C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe':'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe';$command=escapeshellarg($php).' '.escapeshellarg(__FILE__).' '.$mode;passthru($command,$status);if($status)exit($status);}exit;
}
$mode=$argv[1];if(!in_array($mode,['compras','sgf'],true))throw new LogicException('Unknown host');
$s=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/payables-demo-'.$mode.'.json'),true,32,JSON_THROW_ON_ERROR);
foreach($s as $k=>$v){putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
require $s['HOST_ROOT'].'/vendor/autoload.php';$app=require $s['HOST_ROOT'].'/bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$db=\Illuminate\Support\Facades\DB::connection();if(!preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D',$db->getDatabaseName())||$db->selectOne('select current_database() as d')->d!==$s['DB_DATABASE'])throw new LogicException('Wrong QA database');
$role=$db->selectOne('select rolsuper,rolcreatedb,rolcreaterole from pg_roles where rolname=current_user');if($role->rolsuper||$role->rolcreatedb||$role->rolcreaterole)throw new LogicException('Restricted role required');
$counts=[];foreach(['sources','proposals','obligations','events','outbox'] as $t)$counts[$t]=$db->table('pa_'.$t)->count();
$report=['host'=>$mode,'isolated'=>true,'restricted_role'=>true,'counts'=>$counts,'core_sha256'=>hash_file('sha256',__DIR__.'/../packages/payables/src/Payables.php'),'checked_at'=>gmdate('c')];
file_put_contents(__DIR__.'/../docs/proofs/payables-demo-'.$mode.'.json',json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL);echo json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
+32
View File
@@ -0,0 +1,32 @@
// Directed HTTP checks; no business operations or credential output.
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
const fixture=JSON.parse(await fs.readFile('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json','utf8'));
assert.equal(fixture.product_demo?.version,'SGF-PRODUCT-DEMO-v1');
const base='http://127.0.0.1:8002';
const results=[];
for(const [role,email,paths,forbidden] of [
['preparador','gestor@example.test',['workshop','workshop-billing','workshop-corrections','direct-costs','industrial-costs','industrial-budgets','service-invoices','service-receivables'],null],
['validador','consulta@example.test',['workshop','workshop-billing','workshop-corrections','direct-costs','industrial-costs','industrial-budgets','service-invoices','service-receivables'],null],
['tecnico','tecnico-oficina@example.test',['workshop'],'industrial-costs'],
['verificador','verificador-oficina@example.test',['workshop'],'service-receivables'],
]) {
const cookies=new Map();let csrf='',context='';
async function request(path,method='GET',body){
const response=await fetch(base+path,{method,headers:{Accept:'application/json','Content-Type':'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':csrf,'X-SGF-Context':context},...(body===undefined?{}:{body:JSON.stringify(body)})});
for(const c of response.headers.getSetCookie()){const pair=c.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
const data=await response.json().catch(()=>({}));if(data.csrf)csrf=data.csrf;if(data.context?.token)context=data.context.token;
return {status:response.status,data};
}
assert.equal((await request('/api/session')).status,200);
assert.equal((await request('/api/login','POST',{email,password:fixture.password})).status,200,role+' login');
assert.equal((await request('/api/context','POST',{tenant_id:fixture.tenant,company_id:fixture.company,unit_id:fixture.unit})).status,200);
for(const path of paths)assert.equal((await request('/api/'+path)).status,200,role+' '+path);
if(forbidden)assert.equal((await request('/api/'+forbidden)).status,403,role+' forbidden module');
assert.equal((await request('/api/context','POST',{tenant_id:fixture.tenant,company_id:fixture.company,unit_id:fixture.hidden})).status,403,role+' reserved unit');
assert.equal((await request('/api/logout','POST')).status,200);
results.push({role,modules:paths,unauthorised_unit_refused:true,...(forbidden?{unauthorised_module_refused:forbidden}:{})});
}
const report={demo:fixture.product_demo.version,checked_at:new Date().toISOString(),type:'HTTP integration, not visual acceptance',business_operations_changed:false,results};
await fs.writeFile('docs/proofs/product-demo-http.json',JSON.stringify(report,null,2)+'\n');
console.log('Integrated demo HTTP checks passed for four synthetic functions, module access and unit isolation. No business writes.');
+85
View File
@@ -0,0 +1,85 @@
<?php
// Consolidates existing synthetic fixtures; never migrates, resets or touches client data.
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use App\Models\User;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Testing\TestingDatabaseGuard;
use Illuminate\Support\Facades\DB;
use Tests\Support\{DocumentPreviewFixture, WorkshopCorrectionFixture, IndustrialBudgetFixture, ReceivablesFixture};
TestingDatabaseGuard::assertSafe();
\App\Modules\Services\ServiceInvoicing::guard();
$file = 'C:/laragon/data/sgf-tempo/secrets/browser-fixture.json';
$verify = in_array('--verify', $argv, true);
$manifest = is_file($file) ? json_decode(file_get_contents($file), true, 64, JSON_THROW_ON_ERROR) : null;
if (isset($manifest['product_demo'])) {
foreach ($manifest['product_demo']['actors'] as $role => $id) User::findOrFail($id);
echo json_encode(['demo' => $manifest['product_demo'], 'changed' => false], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;
exit;
}
// A missing manifest must never silently adopt unrelated test records.
if (!$manifest && DB::table('users')->exists()) throw new RuntimeException('Existing test data has no manifest. Nothing reset; recover its manifest first.');
if ($manifest && empty($manifest['password'])) throw new RuntimeException('Synthetic credential manifest required; no passwords changed.');
foreach (['ic_lots', 'si_invoices'] as $table) {
if (DB::table($table)->exists()) throw new RuntimeException('Existing isolated scenario requires reconciliation: '.$table.'. Nothing deleted.');
}
DB::beginTransaction();
try {
if ($manifest) {
$base = array_intersect_key($manifest, array_flip(['tenant', 'otherTenant', 'company', 'sibling', 'foreign', 'unit', 'hidden', 'siblingUnit', 'password']));
$base['manager'] = User::findOrFail($manifest['manager_id']);
$base['reader'] = User::findOrFail($manifest['reader_id']);
} else {
$base = DocumentPreviewFixture::create();
$manifest = array_intersect_key($base, array_flip(['tenant', 'otherTenant', 'company', 'sibling', 'foreign', 'unit', 'hidden', 'siblingUnit', 'password']));
$manifest['manager_id'] = $base['manager']->id;
$manifest['reader_id'] = $base['reader']->id;
}
if (isset($manifest['workshop_corrections'])) {
$workshop = $manifest['workshop_corrections'];
$tech = User::where('email', 'tecnico-oficina@example.test')->firstOrFail();
$reviewer = User::where('email', 'verificador-oficina@example.test')->firstOrFail();
if (!DB::table('wo_orders')->where(['id' => $workshop['order'], 'company_id' => $base['company'], 'unit_id' => $base['unit']])->exists()) throw new RuntimeException('Workshop manifest differs.');
} else {
if (DB::table('wo_orders')->exists()) throw new RuntimeException('Existing workshop has no reconciled manifest. Nothing overwritten.');
$w = WorkshopCorrectionFixture::create($base);
$workshop = array_intersect_key($w, array_flip(['order', 'time', 'material', 'service', 'client', 'generation', 'service_draft', 'billing_draft']));
$tech = $w['technician'];
$reviewer = $w['reviewer'];
$manifest['workshop_corrections'] = $workshop;
}
$industry = IndustrialBudgetFixture::create($base);
$receivables = ReceivablesFixture::create($base);
$actors = ['preparador' => $base['manager']->id, 'validador' => $base['reader']->id, 'tecnico' => $tech->id, 'verificador' => $reviewer->id];
if (count(array_unique($actors)) !== 4) throw new RuntimeException('Four distinct synthetic actors required.');
$permissions = app(Permissions::class);
foreach (['preparador' => ['workshop.verify', 'industrial_costs.validate'], 'validador' => ['workshop.record', 'industrial_costs.prepare'], 'tecnico' => ['workshop.verify'], 'verificador' => ['workshop.record']] as $role => $forbidden) {
foreach ($forbidden as $right) if ($permissions->has($actors[$role], $base['tenant'], $base['company'], $right, $base['unit'])) throw new RuntimeException('Role separation differs: '.$role);
}
$demo = ['version' => 'SGF-PRODUCT-DEMO-v1', 'synthetic_only' => true, 'actors' => $actors,
'workshop' => $workshop,
'industry' => array_intersect_key($industry, array_flip(['lot', 'refs', 'result'])),
'receivables' => array_intersect_key($receivables, array_flip(['source', 'draft', 'invoice', 'series', 'rule'])),
'prepared_at' => now()->toIso8601String()];
if ($verify) {
DB::rollBack();
} else {
// Manifest and data are committed together as far as possible; retain old file on write failure.
$encoded = json_encode($manifest + ['product_demo' => $demo], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
if (file_put_contents($file, $encoded, LOCK_EX) === false) throw new RuntimeException('Cannot save protected manifest.');
try { DB::commit(); } catch (\Throwable $e) {
file_put_contents($file, json_encode($manifest, JSON_THROW_ON_ERROR), LOCK_EX);
throw $e;
}
}
echo json_encode(['demo' => $demo, 'rolled_back' => $verify, 'client_database_changed' => false,
'credentials' => 'existing protected local manifest only', 'real_grants_created' => false], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;
} catch (\Throwable $e) {
if (DB::transactionLevel()) DB::rollBack();
throw $e;
}
+5
View File
@@ -104,6 +104,11 @@ switch ($Action) {
'composer' { & $sgfPhp $sgfComposer @ToolArguments; if ($LASTEXITCODE) { throw 'Composer command failed.' } }
'npm' { & $sgfNode $sgfNpmCli @ToolArguments; if ($LASTEXITCODE) { throw 'npm command failed.' } }
'test' {
$demoManifest = Join-Path $sgfData 'secrets\browser-fixture.json'
if (Test-Path -LiteralPath $demoManifest) {
$demoFixture = Get-Content -LiteralPath $demoManifest -Raw | ConvertFrom-Json
if ($demoFixture.product_demo) { throw 'Integrated team demo preserved in sgf_tempo_test. Do not reset it for regression tests; use a separate test environment.' }
}
if (Test-Path -LiteralPath "$sgfRun\browser-proof.json") { throw 'Stop the browser proof before tests reset sgf_tempo_test.' }
Start-SgfDatabase
& $sgfPhp 'vendor/phpunit/phpunit/phpunit' @ToolArguments
@@ -0,0 +1,20 @@
<?php
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use App\Modules\Workshop\WorkshopCorrections;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
WorkshopCorrections::guard();if(DB::table('wo_orders')->exists())throw new RuntimeException('Empty synthetic workshop required; nothing deleted.');
$f=\Tests\Support\WorkshopCorrectionFixture::create();$base=['tenant'=>$f['tenant'],'company'=>$f['company'],'unit'=>$f['unit'],'generation'=>$f['generation']];$checks=[];$key=fn()=>(string)Str::uuid();
$pair=function($one,$two)use($base){$a=new Process([PHP_BINARY,'scripts/workshop-corrections-worker.php','--env=testing'],base_path(),null,json_encode($one+$base+['pause'=>true]));$b=new Process([PHP_BINARY,'scripts/workshop-corrections-worker.php','--env=testing'],base_path(),null,json_encode($two+$base));$a->start();if(!$a->waitUntil(fn($type,$v)=>str_contains($v,'LOCK_HELD')))throw new RuntimeException('Company lock not observed.');$b->start();$a->wait();$b->wait();$out=[];foreach([$a,$b] as $p){if(!$p->isSuccessful())throw new RuntimeException('Worker failed.');$lines=array_values(array_filter(explode("\n",trim($p->getOutput())),fn($s)=>str_starts_with($s,'{')));$out[]=json_decode(end($lines),true,32,JSON_THROW_ON_ERROR);}return $out;};
$check=function($out,bool $replay){if($out[0]['status']!==200||$out[1]['status']!==($replay?200:409)||($replay&&(!$out[1]['result']['replayed']||$out[0]['result']['id']!==$out[1]['result']['id'])))throw new RuntimeException('Unexpected concurrent outcome.');return $out[0]['result'];};
$lines=array_map(fn($id,$price)=>['entry_id'=>$id,'service_id'=>$f['service'],'service_version'=>1,'price'=>$price,'price_source'=>'WORKSHOP-SALES-SYNTHETIC-v2','description'=>'Descrição corrigida sintética.'],[$f['time'],$f['material']],['200','80']);
$p=['actor'=>$f['manager']->id,'method'=>'request','key'=>$key(),'input'=>['version'=>1,'previous_id'=>null,'kind'=>'replace','billing_id'=>$f['billing_draft'],'billing_version'=>1,'service_id'=>$f['service_draft'],'service_version'=>1,'lines'=>$lines,'reason'=>'Correcção sintética 500 para 510.']];$id=$check($pair($p,$p),true)['id'];$checks['request_replay_one_version']=true;
$p['key']=$key();$p['input']['version']=2;$p['input']['previous_id']=$id;$cancel=$p;$cancel['key']=$key();$cancel['input']['kind']='cancel';$cancel['input']['lines']=[];$id=$check($pair($p,$cancel),false)['id'];$checks['replacement_vs_cancellation_one_request_successor']=true;
$r=['actor'=>$f['reader']->id,'method'=>'review','key'=>$key(),'input'=>['id'=>$id,'version'=>2,'decision'=>'approved','reason'=>'Revisão distinta.']];$check($pair($r,$r),true);$checks['review_replay_one_decision']=true;
$a=['actor'=>$f['manager']->id,'method'=>'apply','key'=>$key(),'input'=>['id'=>$id,'version'=>2]];$applied=$check($pair($a,$a),true);$checks['apply_replay_one_successor_pair']=true;
$p['key']=$key();$p['input']['version']=3;$p['input']['previous_id']=$id;$p['input']['kind']='cancel';$p['input']['lines']=[];$p['input']['billing_id']=$applied['billing_draft_id'];$p['input']['service_id']=$applied['service_draft_id'];$id=$check($pair($p,$p),true)['id'];
$r['key']=$key();$r['input']['id']=$id;$r['input']['version']=3;$other=$r;$other['key']=$key();$other['input']['decision']='refused';$check($pair($r,$other),false);$checks['competing_reviews_one_decision']=true;
$a['key']=$key();$a['input']=['id'=>$id,'version'=>3];$b1=['actor'=>$f['manager']->id,'method'=>'b1','key'=>$key(),'input'=>['billing_id'=>$applied['billing_draft_id']]];$check($pair($a,$b1),false);$checks['cancellation_blocks_overlapping_b1']=true;
if(DB::table('wb_claims')->count()!==2||DB::table('wc_applications')->count()!==2||DB::table('b_internal_confirmations')->exists())throw new RuntimeException('History/claims or confirmation differs.');
file_put_contents(base_path('docs/proofs/workshop-corrections-concurrency.json'),json_encode(['policy'=>WorkshopCorrections::POLICY,'database'=>'sgf_tempo_test','processes_per_case'=>2,'checks'=>$checks,'source_claims_unchanged'=>2,'applications'=>2,'b1_created'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Six overlapping correction cases passed: one replacement, one cancellation, two original source claims, no B1.\n";
+8
View File
@@ -0,0 +1,8 @@
<?php
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Modules\Workshop\WorkshopCorrections::guard();$path='C:/laragon/data/sgf-tempo/secrets/browser-fixture.json';$base=json_decode(file_get_contents($path),true,32,JSON_THROW_ON_ERROR);
if(\Illuminate\Support\Facades\DB::table('wo_orders')->exists())throw new RuntimeException('Fresh synthetic workshop required; nothing deleted.');
$f=\Tests\Support\WorkshopCorrectionFixture::create($base+['manager'=>\App\Models\User::findOrFail($base['manager_id']),'reader'=>\App\Models\User::findOrFail($base['reader_id'])]);
file_put_contents($path,json_encode($base+['workshop_corrections'=>array_intersect_key($f,array_flip(['order','time','material','service','client','generation','service_draft','billing_draft']))],JSON_THROW_ON_ERROR));
file_put_contents('C:/laragon/data/sgf-tempo/secrets/workshop-corrections-manual-entry.txt',"Synthetic SGF corrections proof only - http://127.0.0.1:8002\r\nPreparer/applicant: gestor@example.test\r\nReviewer: consulta@example.test\r\nTemporary synthetic password for these two accounts: ".$base['password']."\r\nNever paste credentials into chat, captures or reports.\r\n");
echo "Synthetic original 500 MZN prepared through existing workshop conversion with distinct review. No correction, cancellation or correction review prefilled.\n";
@@ -0,0 +1,9 @@
<?php
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
if(!\App\Modules\Identity\Services\LocalDatabaseGuard::allows())throw new RuntimeException('Own application connection required.');
if(!DB::table('companies')->where(['id'=>1,'tenant_id'=>1,'name'=>'Tempo Lda'])->exists()||!DB::table('units')->where(['id'=>1,'tenant_id'=>1,'company_id'=>1,'name'=>'SEGI'])->exists())throw new RuntimeException('Scope differs.');
$grants=[];foreach([1=>['view','request','apply'],2=>['view','review']] as $actor=>$rights){if(!DB::table('company_memberships')->where(['user_id'=>$actor,'tenant_id'=>1,'company_id'=>1])->exists())throw new RuntimeException('Membership missing.');foreach($rights as $right){$g=['user_id'=>$actor,'tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'permission'=>'workshop_corrections.'.$right];$grants[]=$g+['already_granted'=>DB::table('permission_grants')->where($g)->exists()];}}
$prerequisites=[];foreach([1,2] as $actor){foreach(['workshop.view','workshop_billing.view','unit.view'] as $p)$prerequisites[]=['user_id'=>$actor,'permission'=>$p,'present'=>DB::table('permission_grants')->where(['user_id'=>$actor,'tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'permission'=>$p])->exists()];foreach(['company.view','clients.view','services.view'] as $p)$prerequisites[]=['user_id'=>$actor,'permission'=>$p,'present'=>DB::table('permission_grants')->where(['user_id'=>$actor,'tenant_id'=>1,'company_id'=>1,'permission'=>$p])->whereNull('unit_id')->exists()];}foreach(['service_drafts.view','service_drafts.create','billing_drafts.view','billing_drafts.create'] as $p)$prerequisites[]=['user_id'=>1,'permission'=>$p,'present'=>DB::table('permission_grants')->where(['user_id'=>1,'tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'permission'=>$p])->exists()];
try{\App\Modules\Workshop\WorkshopCorrections::guard();throw new RuntimeException('Real corrections allowed.');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){if($e->getStatusCode()!==403)throw $e;}
echo json_encode(['mode'=>'preview_only','destination'=>'sgf_tempo_dev','migration_130000_applied'=>DB::table('migrations')->where('migration','2026_10_28_130000_create_workshop_billing_corrections')->exists(),'proposed_grants'=>$grants,'existing_prerequisites'=>$prerequisites,'real_corrections_blocked'=>true,'applied'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
+6
View File
@@ -0,0 +1,6 @@
<?php
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Modules\Workshop\WorkshopCorrections::guard();$in=json_decode(stream_get_contents(STDIN),true,32,JSON_THROW_ON_ERROR);app(\App\Shared\Context\CompanyContext::class)->selectFor($in['actor'],$in['tenant'],$in['company'],$in['unit']);
if($in['pause']??false){$paused=false;\Illuminate\Support\Facades\DB::listen(function($q)use(&$paused){if(!$paused&&str_contains($q->sql,'"companies"')&&str_contains($q->sql,'for update')){$paused=true;echo "LOCK_HELD\n";flush();usleep(1200000);}});}
try{$out=$in['method']==='b1'?app(\App\Modules\Services\InternalConfirmation::class)->confirm($in['input']['billing_id'],$in['actor'],1,$in['key']):app(\App\Modules\Workshop\WorkshopCorrections::class)->{$in['method']}($in['generation'],$in['actor'],$in['input'],$in['key']);echo json_encode(['status'=>200,'result'=>$out],JSON_THROW_ON_ERROR).PHP_EOL;}
catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){echo json_encode(['status'=>$e->getStatusCode()],JSON_THROW_ON_ERROR).PHP_EOL;}