commit12
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
// Separate development database/role/app: regression resets cannot erase it.
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {spawnSync} from 'node:child_process';
|
||||
const pointer='C:/laragon/data/sgf-tempo/run/modular-regression-current.json';
|
||||
const state=JSON.parse(fs.readFileSync(pointer,'utf8'));
|
||||
if(!/^[a-f0-9]{12}$/.test(state.id)||path.basename(state.root)!=='modular-regression-'+state.id||state.port!==55439)throw Error('Unexpected isolated host');
|
||||
if(state.development)throw Error('Development host already configured; preserve it.');
|
||||
const database='sgf_modules_dev_'+state.id,role='sgf_modules_qa_'+state.id,password=crypto.randomBytes(30).toString('hex');
|
||||
const app=path.join(state.root,'development-app'),pg='C:/Program Files/PostgreSQL/17/bin';
|
||||
const admin=fs.readFileSync(path.join(state.root,'private/admin-password.txt'),'utf8');
|
||||
function run(exe,args,options={}){const r=spawnSync(exe,args,{encoding:'utf8',windowsHide:true,maxBuffer:8*1024*1024,...options});if(r.status!==0)throw Error((r.stdout||'')+(r.stderr||''));return r.stdout;}
|
||||
const identity=run(path.join(pg,'psql.exe'),['-h','127.0.0.1','-p',String(state.port),'-U','sgf_regression_owner','-d','postgres','-At','-c',"SELECT current_setting('cluster_name')"],{env:{...process.env,PGPASSWORD:admin}}).trim();
|
||||
if(identity!=='sgf_modular_regression_'+state.id)throw Error('Unexpected database cluster');
|
||||
fs.cpSync(state.app,app,{recursive:true,filter:p=>!['storage','.env','.env.testing','.env.browser-testing'].includes(path.relative(state.app,p).split(path.sep)[0])&&!p.includes(path.join('bootstrap','cache')+path.sep)});
|
||||
for(const dir of ['storage/framework/cache/data','storage/framework/sessions','storage/framework/views','storage/logs','bootstrap/cache'])fs.mkdirSync(path.join(app,dir),{recursive:true});
|
||||
if(fs.existsSync(path.join(app,'public/hot')))fs.unlinkSync(path.join(app,'public/hot'));
|
||||
function files(dir){return fs.readdirSync(dir,{withFileTypes:true}).flatMap(e=>e.isDirectory()?files(path.join(dir,e.name)):[path.join(dir,e.name)]);}
|
||||
const adaptations=[];
|
||||
for(const dir of ['app','database','config','tests','scripts'])for(const p of files(path.join(app,dir)).filter(p=>/\.(php|sql|mjs)$/.test(p))){
|
||||
const text=fs.readFileSync(p,'utf8'),next=text.replaceAll('sgf_tempo_test',database).replaceAll('sgf_test_runner',role);
|
||||
if(next!==text){fs.writeFileSync(p,next);adaptations.push(path.relative(app,p));}
|
||||
}
|
||||
fs.writeFileSync(path.join(state.root,'development-identity-adaptations.json'),JSON.stringify(adaptations,null,2));
|
||||
run(path.join(pg,'psql.exe'),['-h','127.0.0.1','-p',String(state.port),'-U','sgf_regression_owner','-d','postgres','-v','ON_ERROR_STOP=1'],{env:{...process.env,PGPASSWORD:admin},input:`CREATE ROLE ${role} LOGIN PASSWORD '${password}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;\nCREATE DATABASE ${database} OWNER ${role};\nREVOKE ALL ON DATABASE ${database} FROM PUBLIC;\n`});
|
||||
const env=`APP_NAME=SGF-Modular-Development\nAPP_KEY=base64:${crypto.randomBytes(32).toString('base64')}\nAPP_DEBUG=false\nAPP_URL=http://127.0.0.1:${state.web_port}\nAPP_ENV=testing\nDB_CONNECTION=pgsql\nDB_HOST=127.0.0.1\nDB_PORT=${state.port}\nDB_DATABASE=${database}\nDB_USERNAME=${role}\nDB_PASSWORD=${password}\nDB_URL=\nSESSION_DRIVER=array\nCACHE_STORE=array\nBCRYPT_ROUNDS=4\n`;
|
||||
fs.writeFileSync(path.join(app,'.env.testing'),env);
|
||||
fs.writeFileSync(path.join(app,'.env'),env.replace('APP_ENV=testing','APP_ENV=local'));
|
||||
run('php',['artisan','migrate','--env=testing','--force','--no-interaction'],{cwd:app});
|
||||
const provision=run('php',['scripts/modular-development-profiles.php',path.join(state.root,'private/development.json')],{cwd:app});
|
||||
state.development={app,database,role,port:state.port,url:`http://127.0.0.1:${state.web_port}`};
|
||||
fs.writeFileSync(pointer,JSON.stringify(state,null,2));fs.writeFileSync(path.join(state.root,'development-provision.json'),provision);
|
||||
console.log(JSON.stringify({configured:true,...state.development,credentials_printed:false}));
|
||||
@@ -0,0 +1,39 @@
|
||||
import fs from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
const state=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json','utf8'));
|
||||
const privateData=JSON.parse(fs.readFileSync(state.root+'/private/development.json','utf8'));
|
||||
const base=`http://127.0.0.1:${state.web_port}`;
|
||||
const proof={synthetic_only:true,host:base,database_port:state.port,profiles:[],pending_associations_unchanged:true,visual_review:false};
|
||||
for(const profile of privateData.profiles){
|
||||
const jar=new Map();let csrf='',token='';
|
||||
async function api(url,method='GET',body,status=200){
|
||||
const r=await fetch(base+url,{method,headers:{Accept:'application/json','Content-Type':'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-CSRF-TOKEN':csrf,'X-SGF-Context':token,'Idempotency-Key':crypto.randomUUID()},...(body===undefined?{}:{body:JSON.stringify(body)})});
|
||||
for(const cookie of r.headers.getSetCookie()){const [pair]=cookie.split(';');const at=pair.indexOf('=');jar.set(pair.slice(0,at),pair.slice(at+1));}
|
||||
const data=await r.json();assert.equal(r.status,status,`${profile.role} ${method} ${url}: ${data.message||'unexpected status'}`);
|
||||
if(data.csrf)csrf=data.csrf;if(data.context?.token)token=data.context.token;
|
||||
return data;
|
||||
}
|
||||
await api('/api/session');await api('/api/login','POST',{email:profile.email,password:privateData.password});
|
||||
const select=module_id=>api('/api/context','POST',{tenant_id:privateData.tenant_id,company_id:privateData.company_id,unit_id:privateData.unit_id,module_id});
|
||||
const session=await select('commercial');
|
||||
assert.equal(session.context.modules.length,6);assert(session.context.modules.every(m=>m.development));
|
||||
const has=p=>session.context.grants.some(g=>g.permission===p);
|
||||
const roleProof={role:profile.role,modules:session.context.modules.map(m=>m.id),prepare:has('direct_costs.prepare'),validate:has('direct_costs.validate'),access_manage:has('access.manage')};
|
||||
assert.equal(roleProof.prepare,profile.role==='preparador');assert.equal(roleProof.validate,profile.role==='revisor');assert.equal(roleProof.access_manage,profile.role==='preparador');
|
||||
const clients=await api('/api/a/clients');assert.equal(clients.records.length,0);assert(clients.scope_notice);
|
||||
if(profile.role==='preparador'){
|
||||
const inventory=await api('/api/module-records');const pending=inventory.records.clients.find(r=>r.id===privateData.pending_record_id);
|
||||
assert(pending);assert.deepEqual(pending.modules,[]);
|
||||
}else{
|
||||
await api('/api/module-records','GET',undefined,403);
|
||||
await api('/api/a/clients','POST',{name:'Must remain forbidden'},403);
|
||||
}
|
||||
for(const module of ['purchasing','stock','workshop','finance','accounting'])await select(module);
|
||||
await select('purchasing');await api('/api/supplier-payables','GET',undefined,403);
|
||||
await select('workshop');await api('/api/workshop');
|
||||
await select('finance');await api('/api/direct-costs');
|
||||
await select('accounting');await api('/api/catalogue-accounting');
|
||||
proof.profiles.push(roleProof);await api('/api/logout','POST');
|
||||
}
|
||||
fs.writeFileSync(state.root+'/development-http-proof.json',JSON.stringify(proof,null,2));
|
||||
console.log(JSON.stringify(proof,null,2));
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
// Provision only the new, disposable modular-development host. No client lookup.
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app=require __DIR__.'/../bootstrap/app.php';
|
||||
$app->loadEnvironmentFrom('.env.testing');
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Support\IdentityFixture;
|
||||
use App\Modules\Services\ServicePermissions;
|
||||
|
||||
App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
$identity=DB::selectOne("SELECT current_setting('cluster_name') AS cluster,current_database() AS db,current_user AS role,inet_server_port() AS port");
|
||||
abort_unless(preg_match('/^sgf_modular_regression_[a-f0-9]{12}$/D',$identity->cluster) && (int)$identity->port===55439 && $identity->db==='sgf_tempo_test' && $identity->role==='sgf_test_runner',403);
|
||||
$private=$argv[1]??'';
|
||||
abort_unless(is_dir(dirname($private)) && basename(dirname($private))==='private',403);
|
||||
$result=DB::transaction(function(){
|
||||
$f=IdentityFixture::create('-modular-'.bin2hex(random_bytes(5)));
|
||||
foreach(['manager'=>'Desenvolvimento — Preparador','reader'=>'Desenvolvimento — Revisor','limited'=>'Desenvolvimento — Consulta'] as $who=>$name){$f[$who]->name=$name;$f[$who]->save();}
|
||||
DB::table('permission_grants')->where('user_id',$f['limited']->id)->where('permission','access.manage')->delete();
|
||||
App\Modules\Identity\Services\Audit::record('access.revoked',$f['manager']->id,$f['tenant'],$f['company'],subject:$f['limited']->id,
|
||||
details:['permission'=>'access.manage','reason'=>'Perfil de consulta sintético novo, sem administração de acessos.']);
|
||||
foreach(['manager','reader','limited'] as $who){
|
||||
foreach(['clients.view','services.view','issuer_documents.view','client_documents.view','catalogue_accounting.view','service_fiscal.view'] as $p)IdentityFixture::grant($f[$who]->id,$f['tenant'],$f['company'],$p);
|
||||
foreach(['service_drafts.view','billing_drafts.view','draft_documents.view','service_invoices.view','service_receivables.view','workshop.view','workshop_billing.view','workshop_corrections.view','direct_costs.view','industrial_costs.view','industrial_budgets.view','industrial_budget_comparisons.view'] as $p)IdentityFixture::grant($f[$who]->id,$f['tenant'],$f['company'],$p,$f['unit']);
|
||||
}
|
||||
foreach(ServicePermissions::AREAS as $area)foreach(['create','edit'] as $action)IdentityFixture::grant($f['manager']->id,$f['tenant'],$f['company'],"$area.$action",ServicePermissions::unitScoped($area)?$f['unit']:null);
|
||||
foreach(['catalogue_accounting.propose','service_fiscal.propose'] as $p)IdentityFixture::grant($f['manager']->id,$f['tenant'],$f['company'],$p);
|
||||
foreach(['catalogue_accounting.approve','service_fiscal.approve'] as $p)IdentityFixture::grant($f['reader']->id,$f['tenant'],$f['company'],$p);
|
||||
foreach(['workshop.open','workshop.assign','workshop.authorize','workshop_billing.prepare','workshop_billing.generate','workshop_corrections.request','workshop_corrections.apply','direct_costs.prepare','industrial_costs.prepare','industrial_budgets.prepare','industrial_budget_comparisons.prepare','service_receivables.prepare','service_receivables.allocate','service_receivables.correction_request'] as $p)IdentityFixture::grant($f['manager']->id,$f['tenant'],$f['company'],$p,$f['unit']);
|
||||
foreach(['workshop_billing.review','workshop_corrections.review','direct_costs.validate','industrial_costs.validate','industrial_budgets.validate','service_receivables.confirm','service_receivables.reconcile','service_receivables.correction_approve'] as $p)IdentityFixture::grant($f['reader']->id,$f['tenant'],$f['company'],$p,$f['unit']);
|
||||
// Pending example intentionally has no module association.
|
||||
$pending=DB::table('a_clients')->insertGetId(['tenant_id'=>$f['tenant'],'company_id'=>$f['company'],'unit_id'=>null,'name'=>'Registo sintético pendente de associação','version'=>1,'data'=>'{}','created_at'=>now(),'updated_at'=>now()]);
|
||||
foreach(['manager','reader','limited'] as $who)App\Modules\Identity\Services\Audit::record('development.profile.created',$f['manager']->id,$f['tenant'],$f['company'],$f['unit'],$f[$who]->id,details:['reason'=>'Perfil sintético isolado de '.$who.'; descoberta não concede operações.']);
|
||||
return ['tenant_id'=>$f['tenant'],'company_id'=>$f['company'],'unit_id'=>$f['unit'],'pending_record_id'=>$pending,
|
||||
'profiles'=>array_map(fn($who)=>['role'=>$who==='manager'?'preparador':($who==='reader'?'revisor':'consulta'),'id'=>$f[$who]->id,'email'=>$f[$who]->email],['manager','reader','limited']),
|
||||
'password'=>$f['password']];
|
||||
});
|
||||
file_put_contents($private,json_encode($result,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR));
|
||||
unset($result['password']);
|
||||
$profiles=implode(',',array_map(fn($p)=>$p['id'].':'.$result['tenant_id'].':'.$result['company_id'],$result['profiles']));
|
||||
$env=file_get_contents(base_path('.env.testing'));
|
||||
$env=preg_replace('/^APP_ENV=.*$/m','APP_ENV=browser-testing',$env);
|
||||
$env=preg_replace('/^SESSION_DRIVER=.*$/m','SESSION_DRIVER=file',$env);
|
||||
$env.="\nSESSION_COOKIE=sgf_modular_development\nSGF_DEVELOPMENT_PROFILES={$profiles}\n";
|
||||
file_put_contents(base_path('.env.browser-testing'),$env);
|
||||
echo json_encode(['configured'=>true,'database_identity'=>(array)$identity,'development'=>$result,'module_count'=>6,'pending_associations_unchanged'=>true],JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
// Read-only fingerprints. No identities, credentials or business documents printed.
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app=require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
use Illuminate\Support\Facades\DB;
|
||||
$identity=DB::selectOne('SELECT current_database() AS database,current_user AS role,inet_server_port() AS port');
|
||||
$tables=[];
|
||||
foreach(['permission_grants','company_memberships','identity_audit'] as $table){
|
||||
$r=DB::selectOne("SELECT count(*) AS rows,md5(coalesce(string_agg(row_to_json(t)::text,E'\\n' ORDER BY row_to_json(t)::text),'')) AS fingerprint FROM {$table} t");
|
||||
$tables[$table]=['rows'=>(int)$r->rows,'fingerprint'=>$r->fingerprint];
|
||||
}
|
||||
echo json_encode(['database'=>(array)$identity,'tables'=>$tables,'guard_sha256'=>hash_file('sha256',base_path('app/Shared/Testing/TestingDatabaseGuard.php')),'purchasing_pointer_sha256'=>is_file(base_path('packages/purchasing/current.json'))?hash_file('sha256',base_path('packages/purchasing/current.json')):null],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,74 @@
|
||||
// Disposable Windows regression host. Never connects to the installed databases.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
|
||||
const source=process.cwd(), runtime='C:/laragon/data/sgf-tempo/run', pg='C:/Program Files/PostgreSQL/17/bin';
|
||||
const pointer=path.join(runtime,'modular-regression-current.json');
|
||||
const action=process.argv[2]||'prepare';
|
||||
function run(exe,args,options={}) {
|
||||
const r=spawnSync(exe,args,{encoding:'utf8',windowsHide:true,maxBuffer:24*1024*1024,...options});
|
||||
if(r.status!==0)throw Error(`${path.basename(exe)} failed (${r.status}): ${(r.stdout||'')+(r.stderr||'')}`);
|
||||
return r.stdout;
|
||||
}
|
||||
function files(dir){return fs.readdirSync(dir,{withFileTypes:true}).flatMap(e=>e.isDirectory()?files(path.join(dir,e.name)):[path.join(dir,e.name)]);}
|
||||
function sync(state){
|
||||
for(const dir of ['app','bootstrap','config','database','resources','routes','tests','packages','scripts','public']) {
|
||||
fs.cpSync(path.join(source,dir),path.join(state.app,dir),{recursive:true,filter:p=>!p.includes('bootstrap'+path.sep+'cache'+path.sep)&&!p.endsWith('.update.lock')});
|
||||
}
|
||||
for(const file of ['artisan','composer.json','composer.lock','phpunit.xml','package.json'])fs.copyFileSync(path.join(source,file),path.join(state.app,file));
|
||||
// The sole staging adaptation to application logic is its isolated loopback
|
||||
// port. The source guard stays fixed and unchanged at 55432.
|
||||
const rewrites=[];
|
||||
for(const dir of ['app','database','tests','scripts'])for(const file of files(path.join(state.app,dir)).filter(p=>/\.(php|sql|ps1|mjs)$/.test(p))){
|
||||
const original=fs.readFileSync(file,'utf8'),adapted=original.replaceAll('55432',String(state.port));
|
||||
if(adapted!==original){fs.writeFileSync(file,adapted);rewrites.push(path.relative(state.app,file));}
|
||||
}
|
||||
fs.writeFileSync(path.join(state.root,'port-adaptations.json'),JSON.stringify(rewrites,null,2));
|
||||
}
|
||||
if(action==='prepare'){
|
||||
const id=crypto.randomBytes(6).toString('hex'),root=path.join(runtime,'modular-regression-'+id);
|
||||
const state={id,root,app:path.join(root,'app'),cluster:path.join(root,'postgres'),port:55439,web_port:8039};
|
||||
fs.mkdirSync(state.app,{recursive:true});
|
||||
fs.mkdirSync(path.join(root,'private'),{recursive:true});
|
||||
const admin=crypto.randomBytes(30).toString('hex'),password=crypto.randomBytes(30).toString('hex');
|
||||
fs.writeFileSync(path.join(root,'private','admin-password.txt'),admin);
|
||||
fs.writeFileSync(path.join(root,'private','runner-password.txt'),password);
|
||||
sync(state);
|
||||
fs.cpSync(path.join(source,'vendor'),path.join(state.app,'vendor'),{recursive:true});
|
||||
for(const dir of ['storage/framework/cache/data','storage/framework/sessions','storage/framework/views','storage/logs','bootstrap/cache'])fs.mkdirSync(path.join(state.app,dir),{recursive:true});
|
||||
const common=`APP_NAME=SGF-Modular-QA\nAPP_KEY=base64:${crypto.randomBytes(32).toString('base64')}\nAPP_DEBUG=false\nAPP_URL=http://127.0.0.1:${state.web_port}\nDB_CONNECTION=pgsql\nDB_HOST=127.0.0.1\nDB_PORT=${state.port}\nDB_DATABASE=sgf_tempo_test\nDB_USERNAME=sgf_test_runner\nDB_PASSWORD=${password}\nDB_URL=\nSESSION_DRIVER=array\nCACHE_STORE=array\n`;
|
||||
fs.writeFileSync(path.join(state.app,'.env.testing'),common+'APP_ENV=testing\n');
|
||||
fs.writeFileSync(path.join(state.app,'.env'),common.replace('DB_DATABASE=sgf_tempo_test','DB_DATABASE=sgf_tempo_dev')+'APP_ENV=local\n');
|
||||
run(path.join(pg,'initdb.exe'),['-D',state.cluster,'-U','sgf_regression_owner','-A','scram-sha-256','--pwfile='+path.join(root,'private','admin-password.txt'),'-E','UTF8','--locale=C']);
|
||||
fs.appendFileSync(path.join(state.cluster,'postgresql.conf'),`\nlisten_addresses='127.0.0.1'\nport=${state.port}\ncluster_name='sgf_modular_regression_${id}'\n`);
|
||||
run(path.join(pg,'pg_ctl.exe'),['-D',state.cluster,'-l',path.join(root,'postgres.log'),'start'],{stdio:'ignore'});
|
||||
run(path.join(pg,'psql.exe'),['-h','127.0.0.1','-p',String(state.port),'-U','sgf_regression_owner','-d','postgres','-v','ON_ERROR_STOP=1'],{
|
||||
env:{...process.env,PGPASSWORD:admin},input:`CREATE ROLE sgf_test_runner LOGIN PASSWORD '${password}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
|
||||
CREATE DATABASE sgf_tempo_test OWNER sgf_test_runner;
|
||||
REVOKE ALL ON DATABASE sgf_tempo_test FROM PUBLIC;
|
||||
CREATE DATABASE sgf_tempo_dev;
|
||||
REVOKE ALL ON DATABASE sgf_tempo_dev FROM PUBLIC;
|
||||
`});
|
||||
fs.writeFileSync(pointer,JSON.stringify(state,null,2));
|
||||
console.log(JSON.stringify({prepared:true,...state,source_guard_unchanged:true,credentials_printed:false}));
|
||||
}else{
|
||||
const state=JSON.parse(fs.readFileSync(pointer,'utf8'));
|
||||
if(path.dirname(path.resolve(state.root))!==path.resolve(runtime)||!/^modular-regression-[a-f0-9]{12}$/.test(path.basename(state.root))
|
||||
||path.resolve(state.app)!==path.resolve(state.root,'app')||path.resolve(state.cluster)!==path.resolve(state.root,'postgres')||state.port!==55439)throw Error('Unexpected runtime root');
|
||||
if(action==='sync'){sync(state);console.log('Isolated source refreshed; original databases and guard untouched.');}
|
||||
else if(action==='test'||action==='focused'){
|
||||
const start=new Date().toISOString();
|
||||
const extra=process.argv.slice(3);
|
||||
const suffix=action==='focused'?'-focused':'';
|
||||
const selection=action==='focused'?['--group','modular-focused']:['--exclude-group','modular-focused'];
|
||||
const r=spawnSync('php',['vendor/phpunit/phpunit/phpunit',...selection,...extra,'--log-junit',path.join(state.root,'junit'+suffix+'.xml')],{
|
||||
cwd:state.app,encoding:'utf8',windowsHide:true,maxBuffer:24*1024*1024,
|
||||
env:{...process.env,SGF_ISOLATED_REGRESSION_RUN:state.id}});
|
||||
fs.writeFileSync(path.join(state.root,'regression-output'+suffix+'.txt'),(r.stdout||'')+(r.stderr||''));
|
||||
console.log(((r.stdout||'')+(r.stderr||'')).split(/\r?\n/).filter(line=>/^(PHPUnit|Runtime:|Configuration:|Time:|OK |Tests:|There (was|were)|[0-9]+\))/.test(line.replace(/\x1b\[[0-9;]*m/g,''))).join('\n'));
|
||||
fs.writeFileSync(path.join(state.root,'result'+suffix+'.json'),JSON.stringify({started_at:start,finished_at:new Date().toISOString(),exit_code:r.status,port:state.port,source_guard_unchanged:true},null,2));
|
||||
process.exitCode=r.status||0;
|
||||
}else throw Error('Use prepare, sync, test or focused');
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {createServer} from 'vite';
|
||||
import {ContextTransition} from '../resources/js/context-transition.js';
|
||||
|
||||
const server=await createServer({server:{middlewareMode:true},appType:'custom'});
|
||||
try {
|
||||
const {default:Navigation,moduleGroups}=await server.ssrLoadModule('/resources/js/ModuleNavigation.jsx');
|
||||
const {navigationGroups}=await server.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
|
||||
const groups=navigationGroups(()=>true,7);
|
||||
assert.deepEqual(moduleGroups(groups,'purchasing').map(g=>g.id),['purchasing','shared_records']);
|
||||
assert.deepEqual(moduleGroups(groups,'commercial').map(g=>g.id),['services','billing','billing_support']);
|
||||
assert.equal(moduleGroups(groups,'invalid').length,0);
|
||||
const html=renderToStaticMarkup(React.createElement(Navigation,{groups,context:{module_id:'workshop',modules:[{id:'workshop',label:'Oficinas'}]},page:'workshop',user:{name:'Synthetic'},navigate(){},changeModule(){},logout(){}}));
|
||||
assert(html.includes('Módulo activo'));assert(html.includes('Navegação de Oficinas'));assert(html.includes('Opções gerais'));
|
||||
assert(!html.includes('Todos os documentos'));assert(!html.includes('Obrigações a fornecedores'));
|
||||
assert(html.includes('aria-current="page"'));assert(!html.includes('consulta@example'));
|
||||
const t=new ContextTransition();
|
||||
const form={isConnected:true,reportValidity:()=>true,requestSubmit:()=>{void t.command(async()=>true);}};
|
||||
t.changed(form);await t.save();assert.equal(t.dirty.size,0);assert.equal(t.saved,1);
|
||||
const failure={...form,requestSubmit:()=>{void t.command(async()=>{throw Error('save refused');}).catch(()=>{});}};
|
||||
t.changed(failure);await assert.rejects(()=>t.save(),/contexto foi mantido/);assert(t.dirty.has(failure));
|
||||
t.discard();assert.equal(t.dirty.size,0);
|
||||
const invalid={...form,reportValidity:()=>false};t.changed(invalid);await assert.rejects(()=>t.save(),/Corrija/);assert(t.dirty.has(invalid));
|
||||
t.discard();const editor={isConnected:true};t.changed(editor);await assert.rejects(()=>t.save(),/gravação explícita/);assert(t.dirty.has(editor));
|
||||
t.discard();t.changed({...form,isConnected:false});t.prune();assert.equal(t.dirty.size,0);
|
||||
// Editing during an in-flight save cannot be discarded by its late response.
|
||||
let finish;const concurrent={...form,requestSubmit:()=>{void t.command(()=>new Promise(resolve=>{finish=resolve;}));}};
|
||||
t.changed(concurrent);const saving=t.save();t.changed(concurrent);finish(true);await assert.rejects(()=>saving,/posteriores/);assert(t.dirty.has(concurrent));
|
||||
console.log('Module navigation and transitions passed: module separation, active context, save, failed save, invalid form, discard, unsupported editor and edits during save. No browser or database.');
|
||||
} finally {await server.close();}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tempo\Distribution\Release;
|
||||
use Tempo\Purchasing\Supply\ReplenishmentProjection;
|
||||
|
||||
// Run this installed copy in the client host; no source checkout or application boot.
|
||||
$host = dirname(__DIR__);
|
||||
require $host.'/vendor/autoload.php';
|
||||
require $host.'/packages/purchasing/bootstrap.php';
|
||||
$current = Release::current($host);
|
||||
$policy = ['daily_milli' => 10000, 'safety_milli' => 20000, 'target_milli' => 100000, 'maximum_milli' => 120000, 'lead_days' => 4, 'approval_days' => 1, 'minimum_milli' => 24000, 'pack_milli' => 12000, 'horizon_days' => 30];
|
||||
$result = (new ReplenishmentProjection)->calculate($policy, 120000, '2026-11-01');
|
||||
if ($result['quantity_milli'] !== 84000) {
|
||||
throw new RuntimeException('O pacote instalado não preserva o resultado de referência.');
|
||||
}
|
||||
$base = strtolower(str_replace('\\', '/', realpath($host))).'/';
|
||||
foreach (get_included_files() as $file) {
|
||||
if (! str_starts_with(strtolower(str_replace('\\', '/', realpath($file))), $base)) {
|
||||
throw new RuntimeException('Foi carregado código fora da instalação cliente.');
|
||||
}
|
||||
}
|
||||
echo json_encode(['version' => $current['version'], 'runtime' => PHP_VERSION, 'all_loaded_code_in_client_host' => true,
|
||||
'source_checkout_required' => false, 'database_connected' => false, 'quantity_milli' => $result['quantity_milli']], JSON_THROW_ON_ERROR)."\n";
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tempo\Distribution\Release;
|
||||
use Tempo\Purchasing\Economic\ProposalScoring;
|
||||
use Tempo\Purchasing\Economic\QuotationPricing;
|
||||
use Tempo\Purchasing\Supply\ReplenishmentProjection;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
require_once dirname(__DIR__).'/packages/module-updates/Release.php';
|
||||
|
||||
$stage = null;
|
||||
$exitCode = 0;
|
||||
try {
|
||||
if (count($argv) !== 3) {
|
||||
throw new RuntimeException('Indique pacote e SHA-256 do manifesto.');
|
||||
}
|
||||
$checked = Release::inspect($argv[1], $argv[2]);
|
||||
$stage = sys_get_temp_dir().'/sgc-purchasing-probe-'.bin2hex(random_bytes(8));
|
||||
if (! mkdir($stage, 0700)) {
|
||||
throw new RuntimeException('Não foi possível preparar a prova isolada.');
|
||||
}
|
||||
foreach ($checked['contents'] as $path => $content) {
|
||||
if (! is_dir(dirname($stage.'/'.$path))) {
|
||||
mkdir(dirname($stage.'/'.$path), 0700, true);
|
||||
}
|
||||
file_put_contents($stage.'/'.$path, $content);
|
||||
}
|
||||
require $stage.'/bootstrap.php';
|
||||
$engine = new ReplenishmentProjection;
|
||||
$policy = ['daily_milli' => 10000, 'safety_milli' => 20000, 'target_milli' => 100000, 'maximum_milli' => 120000, 'lead_days' => 4, 'approval_days' => 1, 'minimum_milli' => 24000, 'pack_milli' => 12000, 'horizon_days' => 30];
|
||||
$result = $engine->calculate($policy, 120000, '2026-11-01');
|
||||
$rate = $engine->calculate([...$policy, 'horizon_days' => 3, 'daily_rate' => ['numerator_milli' => 1000, 'denominator_days' => 3]], 10000, '2026-11-01');
|
||||
$lots = $engine->calculate([...$policy, 'horizon_days' => 2], 10000, '2026-11-01', [], [], 0, [], [['quantity' => 10000, 'last_usable_on' => '2026-11-01']]);
|
||||
if ($result['risk_date'] !== '2026-11-10' || $result['quantity_milli'] !== 84000 || array_column($rate['series'], 'demand_milli') !== [334, 333, 333]
|
||||
|| $lots['series'][1]['expired_milli'] !== 0 || $lots['series'][1]['balance_milli'] !== -10000) {
|
||||
throw new RuntimeException('A versão candidata não preserva os resultados do contrato de integração.');
|
||||
}
|
||||
$scenarios = ['manual', 'exact_historical_rate', 'lot_expiry'];
|
||||
if (in_array('procurement.unit_discounts', $checked['manifest']['module']['capabilities'], true)) {
|
||||
$pricing = new QuotationPricing;
|
||||
if ($pricing->unit('19.99', 'percent', '12.50')['net_unit_minor'] !== 1749 || $pricing->unit('0.03', 'percent', '50')['net_unit_minor'] !== 2) {
|
||||
throw new RuntimeException('Descontos incompatíveis com o contrato.');
|
||||
}
|
||||
$scenarios[] = 'unit_discounts';
|
||||
}
|
||||
if (in_array('procurement.proposal_scoring', $checked['manifest']['module']['capabilities'], true)) {
|
||||
$row = ['eligible' => true, 'subtotal_minor' => 10000, 'delivery_days' => 2, 'weights' => ['price' => 50, 'quality' => 50, 'delivery' => 0, 'history' => 0, 'payment' => 0],
|
||||
'evaluation_count' => 2, 'qualified_evaluations' => array_fill(0, 2, ['quality' => '8', 'history' => '8', 'payment' => '8'])];
|
||||
$ranking = (new ProposalScoring)->rank([['id' => 1, ...$row], ['id' => 2, ...$row]], 2);
|
||||
if ($ranking !== ['scores' => [1 => 900, 2 => 900], 'top_ids' => [1, 2]]) {
|
||||
throw new RuntimeException('Pontuação ou empates incompatíveis com o contrato.');
|
||||
}
|
||||
$scenarios[] = 'proposal_scores_and_ties';
|
||||
}
|
||||
echo json_encode(['version' => $checked['manifest']['version'], 'source_sha256' => $checked['manifest']['source_sha256'], 'runtime' => PHP_VERSION, 'scenarios' => $scenarios, 'passed' => true, 'database_connected' => false], JSON_THROW_ON_ERROR)."\n";
|
||||
} catch (Throwable $error) {
|
||||
fwrite(STDERR, $error->getMessage()."\n");
|
||||
$exitCode = 1;
|
||||
} finally {
|
||||
if ($stage !== null && is_dir($stage)) {
|
||||
$real = realpath($stage);
|
||||
$temporary = str_replace('\\', '/', realpath(sys_get_temp_dir()));
|
||||
if ($real !== false && str_starts_with(str_replace('\\', '/', $real), $temporary.'/sgc-purchasing-probe-')) {
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($real, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) {
|
||||
if ($file->isDir() && ! $file->isLink()) {
|
||||
rmdir($file->getPathname());
|
||||
} else {
|
||||
unlink($file->getPathname());
|
||||
}
|
||||
}
|
||||
rmdir($real);
|
||||
}
|
||||
}
|
||||
}
|
||||
exit($exitCode);
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tempo\Distribution\Release;
|
||||
|
||||
// Identical tooling in both compositions; distribution artifacts are pinned copies.
|
||||
require_once dirname(__DIR__).'/packages/module-updates/Release.php';
|
||||
|
||||
try {
|
||||
$action = $argv[1] ?? '';
|
||||
if ($action === 'publish' && count($argv) === 4) {
|
||||
$result = Release::publish($argv[2], $argv[3]);
|
||||
} elseif ($action === 'inspect' && count($argv) === 4) {
|
||||
$result = Release::inspect($argv[2], $argv[3])['manifest'];
|
||||
} elseif ($action === 'install' && count($argv) === 5) {
|
||||
require_once dirname(__DIR__).'/vendor/autoload.php';
|
||||
$host = realpath($argv[2]);
|
||||
$archive = realpath($argv[3]);
|
||||
if ($host === false || $archive === false) {
|
||||
throw new RuntimeException('Instalação ou pacote inexistente.');
|
||||
}
|
||||
// Execute only the approved candidate, in an isolated PHP process, before switching.
|
||||
$probe = new Process([PHP_BINARY, dirname(__DIR__).'/scripts/purchasing-probe.php', $archive, $argv[4]], dirname(__DIR__), timeout: 30);
|
||||
$probe->mustRun();
|
||||
$result = Release::install($argv[2], $argv[3], $argv[4]);
|
||||
$result['runtime_probe'] = json_decode($probe->getOutput(), true, 32, JSON_THROW_ON_ERROR);
|
||||
} elseif ($action === 'status' && count($argv) === 3) {
|
||||
$result = Release::current($argv[2]) ?? ['installed' => false];
|
||||
} elseif ($action === 'rollback' && count($argv) === 3) {
|
||||
$result = Release::rollback($argv[2]);
|
||||
} elseif ($action === 'check' && count($argv) === 5) {
|
||||
$candidate = Release::inspect($argv[3], $argv[4])['manifest'];
|
||||
$current = Release::current($argv[2]);
|
||||
if ($current !== null && $current['version'] === $candidate['version'] && $current['manifest_sha256'] !== $argv[4]) {
|
||||
throw new RuntimeException('A mesma versão tem conteúdo diferente; reconcilie o canal antes de actualizar.');
|
||||
}
|
||||
$result = ['installed_version' => $current['version'] ?? null, 'candidate_version' => $candidate['version'],
|
||||
'available' => $current === null || version_compare($candidate['version'], $current['version'], '>'),
|
||||
'same_source' => ($current['source_sha256'] ?? null) === $candidate['source_sha256'],
|
||||
'database_changes' => false, 'permissions_changed' => false];
|
||||
} else {
|
||||
throw new RuntimeException('Uso: publish <pacote-origem> <canal>; inspect <zip> <sha-manifesto>; check <host> <zip> <sha-manifesto>; install <host> <zip> <sha-manifesto>; status <host>.');
|
||||
}
|
||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)."\n";
|
||||
} catch (Throwable $error) {
|
||||
fwrite(STDERR, $error->getMessage()."\n");
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID,createHash} from 'node:crypto';
|
||||
import {spawn} from 'node:child_process';
|
||||
import vm from 'node:vm';
|
||||
|
||||
async function probe(mode,job,input={},legacy=false){
|
||||
return new Promise((resolve,reject)=>{
|
||||
const php=mode==='compras'?'C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe':'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe';
|
||||
const p=spawn(php,[legacy?'scripts/payables-probe.php':'scripts/settlements-probe.php',mode,job],{windowsHide:true});let out='',err='';
|
||||
p.stdout.on('data',d=>out+=d);p.stderr.on('data',d=>err+=d);p.on('error',reject);p.on('close',code=>{try{if(code)throw Error('Isolated probe failed '+job+': '+out+err);resolve(JSON.parse(out));}catch(e){reject(e);}});p.stdin.end(JSON.stringify(input));
|
||||
});
|
||||
}
|
||||
const hosts=[];
|
||||
for(const mode of ['compras','sgf']){
|
||||
const base=`http://127.0.0.1:${mode==='compras'?8007:8008}`;
|
||||
function client(){const cookies=new Map();let csrf='',context='';return {
|
||||
async request(path,method='GET',body,key=randomUUID(),form=false){
|
||||
const r=await fetch(base+path,{redirect:'manual',signal:AbortSignal.timeout(20000),method,headers:{Connection:'close',Accept:'application/json','Content-Type':form?'application/x-www-form-urlencoded':'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':csrf,'X-SGF-Context':context,'Idempotency-Key':key},...(body===undefined?{}:{body:form?new URLSearchParams(body):JSON.stringify(body)})});
|
||||
for(const cookie of r.headers.getSetCookie()){const pair=cookie.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
|
||||
const html=await r.text();let data;try{data=JSON.parse(html);}catch{}
|
||||
csrf=html.match(/name="_token" value="([^"]+)"/)?.[1]??data?.csrf??csrf;
|
||||
const embedded=html.match(/const settings=(.*);/);if(embedded){const s=JSON.parse(embedded[1]);csrf=s.csrf;context=s.context??context;new vm.Script(html.match(/<script>([\s\S]*?)<\/script>/)[1]);}
|
||||
if(data?.context?.token)context=data.context.token;
|
||||
return {status:r.status,data,html};
|
||||
},
|
||||
async login(role){assert.equal((await this.request('/demo?next=payments')).status,200);assert.equal((await this.request('/demo/entrar','POST',{role,next:'payments',_token:csrf},undefined,true)).status,302);if(mode==='sgf')await this.request('/api/session');await this.request('/supplier-payments');},
|
||||
async list(){const r=await this.request('/api/supplier-payments');assert.equal(r.status,200);return r.data;},
|
||||
async send(action,body,status=200,key=randomUUID()){const r=await this.request('/api/supplier-payments/'+action,'POST',body,key);assert.equal(r.status,status,mode+' '+action+' '+r.html.slice(0,200));return r.data;},
|
||||
async logout(){await this.request('/demo');assert.equal((await this.request('/demo/sair','POST',{_token:csrf},undefined,true)).status,302);}
|
||||
};}
|
||||
const p=client(),v=client(),q=client();await p.login('preparacao');await v.login('validacao');await q.login('consulta');
|
||||
let d=await q.list();const allocation=d.allocations[0],o=d.obligations[0],payment=d.payments[0];
|
||||
assert.equal(o.balance_minor,760000);assert.equal(payment.state,'confirmed');assert.equal(payment.unallocated_minor,0);assert.equal(d.reversals_available,true);
|
||||
const before=await probe(mode,'inspect');const original=JSON.stringify(allocation);
|
||||
const body={allocation_id:Number(allocation.id),source_revision:allocation.source_revision,reason:'Reverter integralmente a afectação sintética para corrigir o montante'};
|
||||
await p.send('reversal-prepare',{...body,reason:''},422);await p.send('reversal-prepare',{...body,amount:'1000.00'},422);
|
||||
await p.send('reversal-prepare',{...body,source_revision:'outdated'},409);
|
||||
await q.send('reversal-prepare',body,403);await v.send('reversal-prepare',body,403);
|
||||
const race=await probe(mode,'race',{role:'preparacao',action:'reversal-prepare',body});assert.equal(race.waits,2);assert.deepEqual(race.results.map(r=>r.status),[200,200]);
|
||||
const proposal=race.results[0].result;assert.equal(race.results[1].result.proposal_id,proposal.proposal_id);
|
||||
const key=randomUUID();await p.send('reversal-prepare',body,200,key);assert.equal((await p.send('reversal-prepare',body,200,key)).replayed,true);
|
||||
await p.send('reversal-prepare',{...body,reason:'Outro motivo incompatível'},409);await p.send('reversal-prepare',{...body,reason:'Outro motivo incompatível'},409,key);
|
||||
d=await q.list();assert.equal(d.obligations[0].balance_minor,760000);assert.equal(d.payments[0].unallocated_minor,0);assert.equal(d.reversals.length,0);
|
||||
const preparePage=await p.request('/supplier-payments');assert(preparePage.html.includes('Proposta de reversão #'));
|
||||
const validatePage=await v.request('/supplier-payments');assert(validatePage.html.includes('data-action="reversal-confirm"'));
|
||||
const decision={proposal_id:proposal.proposal_id,reason:'Confirmação independente da reversão integral documentada'};
|
||||
await q.send('reversal-confirm',decision,403);await p.send('reversal-confirm',decision,403);
|
||||
await probe(mode,'grant',{role:'preparacao',action:'reversal_confirm',enabled:true});await p.send('reversal-confirm',decision,409);await probe(mode,'grant',{role:'preparacao',action:'reversal_confirm',enabled:false});
|
||||
await probe(mode,'person',{same:true});await v.send('reversal-confirm',decision,409);await probe(mode,'person',{same:false});
|
||||
await probe(mode,'source',{override:{total_minor:1160001}},true);await v.send('reversal-confirm',decision,409);assert.equal((await q.list()).obligations[0].balance_minor,null);await probe(mode,'source',{},true);
|
||||
assert.equal((await probe(mode,'foreign',{action:'reversal-confirm',body:decision})).refused,true);
|
||||
const revoked=await probe(mode,'race',{role:'validacao',action:'reversal-confirm',body:decision,revoke:'reversal_confirm'});assert.equal(revoked.waits,2);assert.deepEqual(revoked.results.map(r=>r.status),[403,403]);await probe(mode,'grant',{role:'validacao',action:'reversal_confirm',enabled:true});
|
||||
const counts=await probe(mode,'inspect'),failedKey=randomUUID();await probe(mode,'fault',{enabled:true,kind:'supplier_allocation.reversed'});
|
||||
await v.send('reversal-confirm',decision,500,failedKey);await probe(mode,'fault',{enabled:false});assert.deepEqual((await probe(mode,'inspect')).counts,counts.counts);
|
||||
assert.equal((await q.list()).obligations[0].balance_minor,760000);
|
||||
const reversal=await v.send('reversal-confirm',decision,200,failedKey);assert.equal((await v.send('reversal-confirm',decision,200,failedKey)).replayed,true);
|
||||
const confirmedRace=await probe(mode,'race',{role:'validacao',action:'reversal-confirm',body:decision});assert.equal(confirmedRace.waits,2);assert.deepEqual(confirmedRace.results.map(r=>r.status),[200,200]);assert(confirmedRace.results.every(r=>r.result.reversal_id===reversal.reversal_id));
|
||||
await v.send('reversal-confirm',{...decision,reason:'Tentativa de segunda reversão incompatível'},409);
|
||||
assert.equal((await p.send('reversal-prepare',body)).proposal_id,proposal.proposal_id);
|
||||
d=await q.list();assert.equal(d.reversals.length,1);assert.equal(d.reversals[0].amount_minor,400000);assert.equal(d.obligations[0].balance_minor,1160000);assert.equal(d.payments[0].unallocated_minor,400000);assert.equal(d.payments[0].state,'confirmed');assert.equal(JSON.stringify(d.allocations[0]),original);
|
||||
const stages=[{stage:'before_reversal',debt_minor:760000,unallocated_minor:0},{stage:'full_reversal',debt_minor:1160000,unallocated_minor:400000}];
|
||||
const oldProposal=d.allocation_proposals[0];
|
||||
await v.send('allocation-decide',{proposal_id:Number(oldProposal.id),decision:'confirmed',reason:oldProposal.decision.reason});
|
||||
assert.equal((await q.list()).obligations[0].balance_minor,1160000,'Repeating a reversed original must not reapply it');
|
||||
const plan={payment_id:Number(payment.id),reference:'SIM-CORRECT-3000',allocations:[{obligation_id:Number(o.id),source_revision:o.source_revision,amount:'3000.00'}],reason:'Nova afectação corrigida depois de reversão integral'};
|
||||
await p.send('allocation-prepare',{...plan,reference:'SIM-CORRECT-EXCESS',allocations:[{...plan.allocations[0],amount:'4000.01'}]},409);
|
||||
await p.send('allocation-prepare',{...plan,reference:'SIM-CORRECT-STALE',allocations:[{...plan.allocations[0],source_revision:'outdated'}]},409);
|
||||
// A new reference can propose the same old plan after compensation; old references still recover the original.
|
||||
const samePlan=await p.send('allocation-prepare',{...plan,reference:'SIM-CORRECT-SAME-4000',allocations:[{...plan.allocations[0],amount:'4000.00'}]});
|
||||
assert.notEqual(samePlan.proposal_id,Number(d.allocation_proposals[0].id));
|
||||
const oldRef=await p.send('allocation-prepare',{...plan,reference:'SIM-INTERFACE-ALLOC-001',allocations:[{...plan.allocations[0],amount:'4000.00'}]});assert.equal(oldRef.proposal_id,Number(d.allocation_proposals[0].id));
|
||||
const correction=await p.send('allocation-prepare',plan);assert.equal((await q.list()).obligations[0].balance_minor,1160000);
|
||||
const correctionDecision={proposal_id:correction.proposal_id,decision:'confirmed',reason:'Confirmação independente da nova afectação de 3 000 MZN'};
|
||||
const correctionRace=await probe(mode,'race',{role:'validacao',action:'allocation-decide',body:correctionDecision});assert.equal(correctionRace.waits,2);assert.deepEqual(correctionRace.results.map(r=>r.status),[200,200]);assert.equal(correctionRace.results[0].result.decision_id,correctionRace.results[1].result.decision_id);
|
||||
await v.send('allocation-decide',{...correctionDecision,proposal_id:samePlan.proposal_id},409);
|
||||
assert.equal((await p.send('allocation-prepare',plan)).proposal_id,correction.proposal_id);await v.send('allocation-decide',correctionDecision);
|
||||
d=await q.list();assert.equal(d.obligations[0].balance_minor,860000);assert.equal(d.payments[0].unallocated_minor,100000);assert.equal(d.obligations[0].principal_minor,1160000);assert.equal(d.obligations[0].due_on,null);assert.equal(d.obligations[0].days_overdue,null);assert.equal(d.allocations.length,2);assert.equal(d.reversals.length,1);assert.equal(d.payments.length,1);
|
||||
assert.equal(JSON.stringify(d.allocations[0]),original);assert.equal((await probe(mode,'inspect')).original_hash,before.original_hash);
|
||||
const reversedEvents=d.events.filter(e=>e.kind==='supplier_allocation.reversed');assert.equal(reversedEvents.length,1);assert.notEqual(String(reversedEvents[0].actor_id),String(d.reversal_proposals[0].prepared_by));assert.notEqual(reversedEvents[0].person,d.reversal_proposals[0].prepared_person);
|
||||
const events=d.events.map(e=>e.id);await q.logout();await q.login('consulta');d=await q.list();assert.deepEqual(d.events.map(e=>e.id),events);assert.equal(d.obligations[0].balance_minor,860000);
|
||||
const page=await q.request('/supplier-payments');assert(page.html.includes('8 600.00 MZN'));assert(page.html.includes('1 000.00 MZN'));assert(page.html.includes('Revertida integralmente'));assert(page.html.includes('Atraso indeterminado'));assert(!page.html.includes('data-action='));
|
||||
const obligations=await q.request(mode==='compras'?'/obrigacoes-fornecedores':'/supplier-payables');assert(obligations.html.includes('8 600.00 MZN'));
|
||||
const integrity=await probe(mode,'reversal-immutable');assert.equal(integrity.immutable_operations_refused,4);assert.equal(integrity.invalid_compensations_refused,3);
|
||||
const final=await probe(mode,'inspect');assert.equal(final.counts.ps_events,final.counts.ps_outbox);assert.equal(final.counts.ps_reversals,1);assert.equal(final.counts.ps_reversal_proposals,1);
|
||||
stages.push({stage:'corrected_allocation',debt_minor:860000,unallocated_minor:100000});
|
||||
hosts.push({host:mode,passed:true,stages,due_on:null,days_overdue:null,originals_preserved:true,full_reversals:1,payments:1,allocations:2,events:events.length,outbox:final.counts.ps_outbox,integrity,races:[race,revoked,confirmedRace,correctionRace],checks:['proposal_has_no_effect','new_key_retry_recovers','incompatible_repeat_conflicts','partial_payload_refused','single_full_compensation','payment_remains_confirmed','distinct_actor_and_person','permission_revocation_after_wait','cross_company_refused','source_version_conflict','rollback_before_event_and_same_key_recovery','balance_limits','same_plan_new_cycle_old_reference_recovery','original_confirmation_cannot_reapply_reversed_effect','canonical_host_balance','history_reentry','read_only_role_without_forms'],visual_review:false});
|
||||
console.log(mode+': reversal/correction passed: 7600 -> 11600 -> 8600; unallocated 0 -> 4000 -> 1000.');
|
||||
}
|
||||
const hash=createHash('sha256').update(await fs.readFile('packages/payables/src/SupplierSettlements.php')).digest('hex');
|
||||
await fs.writeFile('docs/proofs/supplier-reversals-20261030.json',JSON.stringify({version:'COMMON-ALLOCATION-FULL-REVERSAL-SIM-v1',checked_at:new Date().toISOString(),core_sha256:hash,hosts,public_enabled:false,bank_execution:false,accounting:false,persistent_demo_upgraded:false,visual_review:false},null,2)+'\n');
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
// Opt-in upgrade of disposable proofs only. Does not upgrade persistent team demos.
|
||||
$mode = $argv[1] ?? '';
|
||||
if (! in_array($mode, ['compras', 'sgf'], true)) throw new LogicException('Named proof host required.');
|
||||
$private = 'C:/laragon/data/sgf-tempo/secrets/';
|
||||
$s = json_decode(file_get_contents($private.'payables-proof-'.$mode.'.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
$demo = json_decode(file_get_contents($private.'payables-demo-'.$mode.'.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
if ($s['DB_DATABASE'] === $demo['DB_DATABASE']) throw new LogicException('Persistent demo protected.');
|
||||
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();
|
||||
$before = []; $columns = [];
|
||||
foreach (\Tempo\Finance\Payables\SettlementSchema::TABLES as $table) {
|
||||
$columns[$table] = \Illuminate\Support\Facades\DB::connection()->getSchemaBuilder()->getColumnListing($table);
|
||||
$before[$table] = \Tempo\Finance\Payables\Payables::canonical(\Illuminate\Support\Facades\DB::table($table)->orderBy($table === 'ps_outbox' ? 'event_id' : 'id')->get()->all());
|
||||
}
|
||||
\Tempo\Finance\Payables\ReversalSchema::install();
|
||||
foreach ($columns as $table => $names) {
|
||||
$after = \Tempo\Finance\Payables\Payables::canonical(\Illuminate\Support\Facades\DB::table($table)->select($names)->orderBy($table === 'ps_outbox' ? 'event_id' : 'id')->get()->all());
|
||||
if ($before[$table] !== $after) throw new LogicException('Existing rows changed during upgrade.');
|
||||
}
|
||||
$f = json_decode(file_get_contents($s['PAYABLE_ACCESS_FILE']), true, 32, JSON_THROW_ON_ERROR);
|
||||
\Illuminate\Support\Facades\DB::transaction(function () use ($f): void {
|
||||
foreach (['preparacao' => 'reversal_prepare', 'validacao' => 'reversal_confirm'] as $role => $action) {
|
||||
$person = \Illuminate\Support\Facades\DB::table('pa_access')->where($f['scope'])->where(['user_id' => $f['accounts'][$role]['id'], 'action' => 'view'])->value('person');
|
||||
if ($person !== 'synthetic-person-'.$role) throw new LogicException('Synthetic identity required.');
|
||||
\Illuminate\Support\Facades\DB::table('pa_access')->insert($f['scope'] + ['user_id' => $f['accounts'][$role]['id'], 'person' => $person, 'action' => $action]);
|
||||
}
|
||||
});
|
||||
echo "Explicit reversal upgrade and two synthetic grants installed on disposable $mode proof only.\n";
|
||||
echo "All nine existing settlement tables preserved, including original allocations and decisions.\n";
|
||||
@@ -72,7 +72,7 @@ for(const mode of ['compras','sgf']){
|
||||
assert.equal(data.obligations[0].due_on,null);assert.equal(data.obligations[0].days_overdue,null);
|
||||
const afterFirst=await probe(mode,'inspect',{},true);assert.deepEqual(afterFirst.domain_hashes,before.domain_hashes);if(mode==='compras')assert.equal(afterFirst.legacy_balance,760000);
|
||||
const html=await q.request('/supplier-payments');assert.equal(html.status,200);assert(html.text.includes('7 600.00 MZN'));assert(!html.text.includes('<form data-action='));assert(html.text.includes('Dias de atraso: sem valor'));
|
||||
const prepHtml=await p.request('/supplier-payments');assert(prepHtml.text.includes('data-action="payment-prepare"'));assert(prepHtml.text.includes('data-action="allocation-prepare"'));
|
||||
const prepHtml=await p.request('/supplier-payments');assert(prepHtml.text.includes('data-action="payment-prepare"'));assert(!prepHtml.text.includes('data-action="allocation-prepare"'),'Fully allocated payment must not be offered again');
|
||||
assert.equal((await probe(mode,'foreign',{body:decision})).refused,true);
|
||||
const immutable=await probe(mode,'immutable');assert.equal(immutable.refused,3);
|
||||
const more=[];
|
||||
@@ -105,5 +105,5 @@ for(const mode of ['compras','sgf']){
|
||||
races:[revoked,paymentRace,planRace,allocationRevoke,allocationRace,overRace],checks:['payment_confirmation_does_not_reduce_debt','explicit_allocations_only','new_request_keys_recover_same_fact','duplicate_evidence_conflicts','partial_and_excess','overallocation_refused','rejected_payment_or_allocation_has_no_effect','separate_actor_and_person','revocation_while_waiting','atomic_fault_rollback_and_retry','source_revision_conflict','cross_company_refused','cross_supplier_refused','history_not_ignored','immutable_confirmed_facts','legacy_balance_uses_common_position','unchanged_stock_budget_invoice_legacy_payments','csrf_and_context','logout_reentry_history','durable_outbox'],visual_review:false});
|
||||
}
|
||||
assert.equal(hosts[0].core_sha256,hosts[1].core_sha256);
|
||||
await fs.writeFile('docs/proofs/supplier-settlements.json',JSON.stringify({version:'COMMON-SUPPLIER-SETTLEMENT-SIM-v1',checked_at:new Date().toISOString(),hosts,bank_execution:false,accounting:false,real_migration:false},null,2)+'\n');
|
||||
await fs.writeFile(process.env.SETTLEMENTS_PROOF_OUTPUT??'docs/proofs/supplier-settlements.json',JSON.stringify({version:'COMMON-SUPPLIER-SETTLEMENT-SIM-v1',checked_at:new Date().toISOString(),hosts,bank_execution:false,accounting:false,real_migration:false},null,2)+'\n');
|
||||
console.log('Both hosts passed: explicit supplier settlements, canonical balance, duplicate/concurrent requests, failure recovery and preserved source/stock/budget/history.');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import fs from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID,createHash} from 'node:crypto';
|
||||
import vm from 'node:vm';
|
||||
@@ -69,5 +69,5 @@ for(const mode of ['compras','sgf']) {
|
||||
hosts.push({host:mode,passed:true,kind:'HTTP pages, form availability, submitted commands and JavaScript syntax; not browser execution',stages,principal_minor:1160000,due_on:null,days_overdue:null,payments:1,allocations:1,repeat_recovered:true,payment_confirmation_repeat_recovered:true,event_counts:eventCounts,distinct_validation_actors:true,history_after_reentry:true,canonical_balance_on_both_pages:true,role_destination_retained:true,external_redirect_refused:true,read_only_without_forms:true,visual_review:false});
|
||||
console.log(mode+': full synthetic sequence passed; single payment/allocation, debt 11600 before allocation and 7600 after.');
|
||||
}
|
||||
const core=await fs.readFile('packages/payables/src/SupplierSettlements.php');const coreSha=createHash('sha256').update(core).digest('hex');const accepted=JSON.parse(await fs.readFile('docs/proofs/supplier-settlements.json','utf8'));assert(accepted.hosts.every(h=>h.core_sha256===coreSha),'Financial core changed since accepted proof');
|
||||
await fs.writeFile('docs/proofs/supplier-settlements-consolidation-20261030.json',JSON.stringify({checked_at:new Date().toISOString(),core_unchanged:true,core_sha256:coreSha,hosts,visual_blocker:'Previous Firefox attempt stopped: browser URL policy enforcement is not yet supported for the current Windows browser. Browser not retried in this consolidation.',public_enabled:false,bank_execution:false,accepted_proof_preserved:true},null,2)+'\n');
|
||||
const core=await fs.readFile('packages/payables/src/SupplierSettlements.php');const coreSha=createHash('sha256').update(core).digest('hex');const accepted=JSON.parse(await fs.readFile('docs/proofs/supplier-settlements.json','utf8'));const unchanged=accepted.hosts.every(h=>h.core_sha256===coreSha);assert(unchanged||process.env.SETTLEMENTS_REVERSAL_REGRESSION==='1','Financial core changed since accepted proof');
|
||||
await fs.writeFile(process.env.SETTLEMENTS_REVERSAL_REGRESSION==='1'?'docs/proofs/supplier-reversals-baseline-20261030.json':'docs/proofs/supplier-settlements-consolidation-20261030.json',JSON.stringify({checked_at:new Date().toISOString(),core_unchanged:unchanged,core_sha256:coreSha,hosts,visual_blocker:'Previous Firefox attempt stopped: browser URL policy enforcement is not yet supported for the current Windows browser. Browser not retried in this consolidation.',public_enabled:false,bank_execution:false,accepted_proof_preserved:true},null,2)+'\n');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import os,socket,subprocess,time,json,pathlib
|
||||
import os,socket,subprocess,time,json,pathlib,sys
|
||||
root=pathlib.Path('C:/laragon/www/sgf-tempo');os.chdir(root)
|
||||
php='C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe';node='C:/laragon/tools/sgf-tempo/node-v24.22.1-win-arm64/node.exe';processes=[];created=False
|
||||
for port in [8007,8008]:
|
||||
@@ -16,7 +16,18 @@ try:
|
||||
time.sleep(.1)
|
||||
if not ready:raise RuntimeError('Disposable HTTP host failed')
|
||||
subprocess.run([php,'scripts/settlements-install.php',mode,'proof'],check=True)
|
||||
subprocess.run([node,'scripts/settlements-interface-check.mjs'],check=True)
|
||||
if '--adversarial' in sys.argv:
|
||||
if '--upgraded' in sys.argv:
|
||||
for mode in ['compras','sgf']:subprocess.run([php,'scripts/reversals-install.php',mode],check=True)
|
||||
subprocess.run([node,'scripts/settlements-http-proof.mjs'],check=True,env={**os.environ,'SETTLEMENTS_PROOF_OUTPUT':'docs/proofs/supplier-reversals-legacy-regression-20261030.json'})
|
||||
elif '--reversals' in sys.argv:
|
||||
subprocess.run([node,'scripts/settlements-interface-check.mjs'],check=True,env={**os.environ,'SETTLEMENTS_REVERSAL_REGRESSION':'1'})
|
||||
for mode in ['compras','sgf']:subprocess.run([php,'scripts/reversals-install.php',mode],check=True)
|
||||
subprocess.run([node,'scripts/reversals-http-proof.mjs'],check=True)
|
||||
else:subprocess.run([node,'scripts/settlements-interface-check.mjs'],check=True)
|
||||
if '--review' in sys.argv:
|
||||
print('Isolated review: Compras http://127.0.0.1:8007/demo?next=payments ; SGF http://127.0.0.1:8008/demo?next=payments . Ctrl+C stops and cleans these disposable databases.',flush=True)
|
||||
while True:time.sleep(1)
|
||||
finally:
|
||||
for p in processes:
|
||||
if p.poll() is None:p.terminate();p.wait(timeout=15)
|
||||
|
||||
@@ -23,9 +23,10 @@ $select = function (string $role) use ($f, $mode) {
|
||||
return $u;
|
||||
};
|
||||
if ($job === 'inspect') {
|
||||
$counts = []; foreach (SettlementSchema::TABLES as $table) $counts[$table] = $db->table($table)->count();
|
||||
$counts = []; foreach (array_merge(SettlementSchema::TABLES, \Tempo\Finance\Payables\ReversalSchema::TABLES) as $table) if ($db->getSchemaBuilder()->hasTable($table)) $counts[$table] = $db->table($table)->count();
|
||||
$out = ['counts' => $counts, 'allocation_events' => $db->table('ps_events')->where('kind', 'supplier_allocation.confirmed')->count(),
|
||||
'core_sha256' => hash_file('sha256', __DIR__.'/../packages/payables/src/SupplierSettlements.php')];
|
||||
'core_sha256' => hash_file('sha256', __DIR__.'/../packages/payables/src/SupplierSettlements.php'),
|
||||
'original_hash' => hash('sha256', Payables::canonical([$db->table('ps_payments')->orderBy('id')->get()->all(), $db->table('ps_payment_decisions')->orderBy('id')->get()->all(), $db->table('ps_allocations')->orderBy('id')->first(), $db->table('pa_obligations')->orderBy('id')->get()->all()]))];
|
||||
} elseif ($job === 'worker') {
|
||||
$u = $select($input['role']); $db->select('select set_config(?, ?, false)', ['application_name', $input['label']]);
|
||||
try { $out = ['status' => 200, 'result' => app($class)->engine($u)->settlements()->command($input['action'], $input['body'], $input['key'])]; }
|
||||
@@ -56,17 +57,40 @@ if ($job === 'inspect') {
|
||||
$out = ['waits' => $waits, 'results' => $results];
|
||||
} finally { if ($db->transactionLevel()) $db->rollBack(); foreach ($processes as $p) if ($p->isRunning()) $p->stop(); }
|
||||
} elseif ($job === 'fault') {
|
||||
if ($input['enabled']) $db->unprepared("CREATE FUNCTION ps_qa_fault() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.kind='supplier_allocation.confirmed' THEN RAISE EXCEPTION 'PS_QA_FAULT'; END IF; RETURN NEW; END $$; CREATE TRIGGER ps_qa_fault BEFORE INSERT ON ps_events FOR EACH ROW EXECUTE FUNCTION ps_qa_fault()");
|
||||
$kind = $input['kind'] ?? 'supplier_allocation.confirmed';
|
||||
if (! in_array($kind, ['supplier_allocation.confirmed', 'supplier_allocation.reversed'], true)) throw new LogicException('Invalid proof fault kind.');
|
||||
if ($input['enabled']) $db->unprepared("CREATE FUNCTION ps_qa_fault() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.kind='{$kind}' THEN RAISE EXCEPTION 'PS_QA_FAULT'; END IF; RETURN NEW; END $$; CREATE TRIGGER ps_qa_fault BEFORE INSERT ON ps_events FOR EACH ROW EXECUTE FUNCTION ps_qa_fault()");
|
||||
else $db->unprepared('DROP TRIGGER ps_qa_fault ON ps_events; DROP FUNCTION ps_qa_fault()');
|
||||
$out = ['configured' => true];
|
||||
} elseif ($job === 'grant') {
|
||||
if (! in_array($input['action'], ['payments_view', 'payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm'], true)) throw new LogicException('Invalid QA action.');
|
||||
if (! in_array($input['action'], ['payments_view', 'payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm', 'reversal_prepare', 'reversal_confirm'], true)) throw new LogicException('Invalid QA action.');
|
||||
$role = $input['role']; $match = $f['scope'] + ['user_id' => $f['accounts'][$role]['id'], 'action' => $input['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 === 'person') {
|
||||
$db->table('pa_access')->where($f['scope'])->where(['user_id' => $f['accounts']['validacao']['id'], 'action' => 'view'])->update(['person' => $input['same'] ? 'synthetic-person-preparacao' : 'synthetic-person-validacao']);
|
||||
$out = ['changed' => true];
|
||||
} elseif ($job === 'reversal-immutable') {
|
||||
$refused = 0;
|
||||
foreach (\Tempo\Finance\Payables\ReversalSchema::TABLES as $table) {
|
||||
foreach (['update', 'delete'] as $operation) {
|
||||
try { DB::transaction(function () use ($db, $table, $operation): void {
|
||||
$q = $db->table($table)->where('id', $db->table($table)->min('id'));
|
||||
if ($operation === 'delete') $q->delete(); else $q->update(['reason' => 'Forbidden alteration']);
|
||||
}); } catch (\Illuminate\Database\QueryException) { $refused++; }
|
||||
}
|
||||
}
|
||||
$row = (array) $db->table('ps_reversals')->first(); unset($row['id']);
|
||||
$proposal = $db->table('ps_reversal_proposals')->where('id', $row['proposal_id'])->first();
|
||||
$invalid = 0;
|
||||
foreach ([['amount_minor' => (int) $row['amount_minor'] - 1], ['actor_id' => $proposal->prepared_by, 'person' => $proposal->prepared_person], ['unit_id' => 999999]] as $override) {
|
||||
try { DB::transaction(fn () => $db->table('ps_reversals')->insert(array_replace($row, $override))); }
|
||||
catch (\Illuminate\Database\QueryException $e) {
|
||||
if (! str_contains($e->getMessage(), 'Invalid full allocation compensation')) throw $e;
|
||||
$invalid++;
|
||||
}
|
||||
}
|
||||
$out = ['immutable_operations_refused' => $refused, 'invalid_compensations_refused' => $invalid];
|
||||
} elseif ($job === 'immutable') {
|
||||
$refused = 0;
|
||||
foreach (['ps_payments', 'ps_payment_decisions', 'ps_allocations'] as $table) {
|
||||
@@ -86,9 +110,9 @@ if ($job === 'inspect') {
|
||||
$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 => $which) $db->table('permission_grants')->insert(['tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $which, 'permission' => $permission, 'user_id' => $u->id, 'created_at' => $now, 'updated_at' => $now]);
|
||||
}
|
||||
foreach (['view', 'payments_view', 'payment_confirm', 'allocation_confirm'] as $action) $db->table('pa_access')->insert($scope + ['user_id' => $u->id, 'action' => $action, 'person' => 'foreign-synthetic-person']);
|
||||
foreach (['view', 'payments_view', 'payment_confirm', 'allocation_confirm', 'reversal_prepare', 'reversal_confirm'] as $action) $db->table('pa_access')->insert($scope + ['user_id' => $u->id, 'action' => $action, 'person' => 'foreign-synthetic-person']);
|
||||
auth()->guard()->setUser($u); if ($mode === 'sgf') app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id, $tenant, $company, $unit);
|
||||
try { app($class)->engine($u)->settlements()->command('payment-decide', $input['body'], \Illuminate\Support\Str::uuid()->toString()); $out = ['refused' => false]; }
|
||||
try { app($class)->engine($u)->settlements()->command($input['action'] ?? 'payment-decide', $input['body'], \Illuminate\Support\Str::uuid()->toString()); $out = ['refused' => false]; }
|
||||
catch (\DomainException) { $out = ['refused' => true]; }
|
||||
} elseif ($job === 'multi') {
|
||||
// Core contract test with explicit synthetic source adapter, distinct from the real host source proof.
|
||||
|
||||
Reference in New Issue
Block a user