Files
SGF/scripts/sgc-invoice-security-proof.php
2026-11-11 09:00:17 +02:00

46 lines
5.7 KiB
PHP

<?php
require __DIR__.'/sgc-invoice-proof-bootstrap.php';
use Illuminate\Support\Facades\{Crypt,DB};
use Illuminate\Support\Str;
if(!is_file($proofRoot.'/result.json')||is_file($proofRoot.'/security-result.json'))throw new LogicException('Completed base proof and fresh continuation required');
$base=json_decode(file_get_contents($proofRoot.'/result.json'),true);
$p=json_decode(file_get_contents($proofRoot.'/initial-preview.json'),true);
$cut=json_decode(Crypt::decryptString($p['data']['preview']),true);
$key=$cut['operation_key'];$orderId=$cut['order_id'];
$body=['phase'=>'preview','order_id'=>$orderId,'revision'=>$cut['revision'],'reason'=>$cut['reason'],'data'=>$cut['data']];
$confirmation=['phase'=>'confirm','order_id'=>$orderId,'preview'=>$p['data']['preview']];
$checks=[];$ok=function($value,$name)use(&$checks,$proofRoot){if(!$value)throw new RuntimeException($name);$checks[]=$name;file_put_contents($proofRoot.'/security-progress.json',json_encode($checks));};
$beforeInvoices=$native->table('invoices')->orderBy('id')->get()->toJson();$beforeAudit=$native->table('audit_logs')->count();
$r=$send($confirmation,$key);$ok($r['status']===200,'existing confirmed invoice recovered without new business write');$invoice=$r['data']['invoice_id'];
foreach(['tenant_id','company_id']as$field){$bad=$principal;$bad[$field]=999999;config(['sgc_invoice.principals.synthetic'=>$bad]);$ok($send($confirmation,$key)['status']===403,'foreign '.$field.' principal rejected');}
$bad=$principal;$bad['enabled']=false;config(['sgc_invoice.principals.synthetic'=>$bad]);$ok($send($confirmation,$key)['status']===401,'disabled principal rejected even for replay');
$bad=$principal;$bad['token_sha256']=hash('sha256',str_repeat('r',64));config(['sgc_invoice.principals.synthetic'=>$bad]);$ok($send($confirmation,$key)['status']===401,'rotated credential invalidates old bearer');
$bad=$principal;$bad['context_version']=hash('sha256','new synthetic context');config(['sgc_invoice.principals.synthetic'=>$bad]);
$ok($send($confirmation,$key)['status']===409,'old context header rejected after rotation');
$ok($send($confirmation,$key,['HTTP_X_SGC_CONTEXT'=>$bad['context_version']])['status']===409,'old preview rejected with new context header');
config(['sgc_invoice.principals.synthetic'=>$principal]);
config(['modules.companies.'.$f['tenant'].':'.$f['company']=>['contracted'=>[],'enabled'=>[]]]);
$ok($send($confirmation,$key)['status']===403,'disabled purchasing module refuses API replay');config(['modules.companies'=>[]]);
$oldOrder=$native->table('purchase_orders')->where('tenant_id',$nativeFixture['tenant'])->where('id','<>',$orderId)->first();
$bad=$body;$bad['order_id']=(int)$oldOrder->id;$ok($send($bad,$key)['status']===409,'same key cannot be moved to another authorised order');
$ok($send($confirmation,strtoupper($key))['status']===200,'UUID case does not create a second operation');
$ok($send($body,$key,[], '/api/v1/integration/sgc/invoices?company_id=1')['status']===422,'query cannot inject another context');
$bad=$body;$bad['data']['items'][0]['tenant_id']=1;$ok($send($bad,$key)['status']===422,'nested unknown authority field rejected');
$bad=$body;$bad['phase']='validate';$ok($send($bad,$key)['status']===422,'transport cannot approve or consume invoice');
$ok($send($body,$key,[], '/api/v1/integration/sgc/payments')['status']===404,'legacy payment route remains unmounted');
$expired=$cut;$expired['observed']=time()-601;$old=Crypt::encryptString(json_encode($expired));
$ok($send(['phase'=>'confirm','order_id'=>$orderId,'preview'=>$old],$key)['status']===200,'durable receipt recovered after preview lifetime with current authority');
$expired['operation_key']=(string)Str::uuid();$old=Crypt::encryptString(json_encode($expired));
$ok($send(['phase'=>'confirm','order_id'=>$orderId,'preview'=>$old],$expired['operation_key'])['status']===409,'expired unapplied preview cannot write');
config(['database.connections.sgc_blocker'=>config('database.connections.'.DB::getDefaultConnection())]);$blocker=DB::connection('sgc_blocker');
file_put_contents($proofRoot.'/lock-job.json',json_encode(['body'=>$confirmation,'key'=>$key],JSON_THROW_ON_ERROR));
$worker=function(string $output)use($proofRoot,$repo){$p=new Symfony\Component\Process\Process([PHP_BINARY,__DIR__.'/sgc-invoice-proof.php','--worker','lock-job.json',$output],$repo,['SGC_INVOICE_PROOF_ROOT'=>$proofRoot],null,20);$p->run();if(!$p->isSuccessful())throw new RuntimeException('Lock worker failed');return json_decode(file_get_contents($proofRoot.'/'.$output),true);};
$blocker->beginTransaction();try{$blocker->select('SELECT pg_advisory_xact_lock(hashtextextended(?,0))',['sgc.invoice:'.$key]);$blocked=$worker('blocked.json');}finally{$blocker->rollBack();}
$ok($blocked['status']===503&&($blocked['data']['error']??null)==='RETRY_SAME_KEY','lock timeout returns recoverable503 without duplicate');
$recovered=$worker('unblocked.json');$ok($recovered['status']===200&&$recovered['data']['invoice_id']===$invoice,'after lock release same operation recovers correct invoice');
$ok($beforeInvoices===$native->table('invoices')->orderBy('id')->get()->toJson()&&$beforeAudit===$native->table('audit_logs')->count(),'security and lock continuation added no invoices or audit commands');
$defaults=require $repo.'/config/sgc_invoice.php';$ok($defaults['enabled']===false&&$defaults['principals']===[],'source configuration remains disabled with no principals');
file_put_contents($proofRoot.'/security-result.json',json_encode(['passed'=>true,'checks'=>$checks,'new_business_rows'=>0,'lock_statuses'=>[$blocked['status'],$recovered['status']],
'base_proof_preserved_sha256'=>hash_file('sha256',$proofRoot.'/result.json'),'no_real_activation'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo json_encode(['passed'=>true,'checks'=>count($checks)]).PHP_EOL;