commit14
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use Tempo\Integration\WarehouseFeed;
|
||||
use Tempo\Integration\WarehouseInbox;
|
||||
|
||||
require_once __DIR__.'/../../packages/integration/bootstrap.php';
|
||||
|
||||
/** Only disposable test stores; never installs the receiver in an application. */
|
||||
function inboxConnection(): PDO
|
||||
{
|
||||
if (getenv('INBOX_TEST_PG') === '1') {
|
||||
$name = getenv('DB_DATABASE');
|
||||
if (getenv('DB_HOST') !== '127.0.0.1' || ! is_string($name) || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', $name)) {
|
||||
throw new LogicException('Inbox PG exige uma base descartável identificada.');
|
||||
}
|
||||
$db = new PDO('pgsql:host=127.0.0.1;port='.getenv('DB_PORT').';dbname='.$name, getenv('DB_USERNAME'), getenv('DB_PASSWORD'), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
if ($db->query('SELECT current_database()')->fetchColumn() !== $name) {
|
||||
throw new LogicException('Destino de teste divergente.');
|
||||
}
|
||||
} else {
|
||||
$db = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
}
|
||||
|
||||
return $db;
|
||||
}
|
||||
|
||||
function inboxDatabase(): PDO
|
||||
{
|
||||
$db = inboxConnection();
|
||||
if (getenv('INBOX_TEST_PG') === '1') {
|
||||
$db->exec('DROP TABLE IF EXISTS sgc_exchange_facts');
|
||||
$db->exec('DROP TABLE IF EXISTS sgc_exchange_streams');
|
||||
}
|
||||
foreach (WarehouseInbox::schema() as $sql) {
|
||||
$db->exec($sql);
|
||||
}
|
||||
|
||||
return $db;
|
||||
}
|
||||
|
||||
function inboxEvent(int $id = 1, string $action = 'deliver', ?string $delivery = null): array
|
||||
{
|
||||
$event = ['schema' => 'sgc.warehouse.fulfilment-event.v1', 'idempotency_key' => 'sgc:7:warehouse-event:'.$id,
|
||||
'tenant_id' => 7, 'event_id' => $id, 'event_version' => $id + 2, 'action' => $action,
|
||||
'occurred_at' => '2026-11-02T10:00:00+00:00', 'actor' => ['id' => 3, 'name' => 'Operador sintético'],
|
||||
'request' => ['id' => 19, 'reference' => 'RA-19', 'document_type' => 'internal_warehouse_request',
|
||||
'work_context_key' => 'procurement.internal', 'requester' => ['id' => 5, 'name' => 'Requisitante sintético', 'department_id' => 2],
|
||||
'warehouse' => ['id' => 1, 'code' => 'ARM', 'name' => 'Armazém sintético'], 'cost_center' => ['id' => 2, 'code' => 'CC', 'name' => 'Centro sintético'], 'destination' => 'Oficina sintética'],
|
||||
'lines' => $action === 'deliver' ? [['request_line_index' => 0, 'item' => ['id' => 4, 'code' => 'ART', 'name' => 'Artigo sintético', 'unit' => 'un'],
|
||||
'quantity_milli' => 1500, 'use' => 'internal', 'destination' => 'Oficina sintética', 'equipment' => null, 'stock_operation_id' => $id + 10]] : [],
|
||||
'delivery_event_key' => $delivery];
|
||||
|
||||
return inboxSign($event);
|
||||
}
|
||||
|
||||
function inboxSign(array $event): array
|
||||
{
|
||||
unset($event['payload_sha256']);
|
||||
|
||||
return [...$event, 'payload_sha256' => hash('sha256', WarehouseFeed::json($event))];
|
||||
}
|
||||
|
||||
function inboxPage(array $events, string $cursor = 'synthetic-opaque-cursor', bool $more = false): array
|
||||
{
|
||||
return ['schema' => 'sgc.warehouse.fulfilment-feed.v1', 'items' => $events, 'next_cursor' => $cursor, 'has_more' => $more];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$file=realpath($argv[1]??'');
|
||||
if(!$root||!$file||dirname($file)!==$root)throw new RuntimeException('Unexpected private worker');
|
||||
require getcwd().'/vendor/autoload.php';$app=require getcwd().'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$db=Illuminate\Support\Facades\DB::connection();$i=$db->selectOne("SELECT current_database() AS name,current_user AS role,current_setting('cluster_name') AS cluster");
|
||||
if(!preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D',$i->name,$m)||$i->role!=='stock_receipt_qa_'.$m[1]||$i->cluster!=='sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN'))throw new RuntimeException('Wrong receipt worker database');
|
||||
$input=json_decode(file_get_contents($file),true,512,JSON_THROW_ON_ERROR);
|
||||
$db->select("SELECT set_config('application_name',?,false)",[$input['application']]);$db->statement("SET lock_timeout='8s'");$db->statement("SET statement_timeout='12s'");
|
||||
$actor=App\Models\User::findOrFail($input['actor']);auth()->guard()->setUser($actor);
|
||||
file_put_contents($file.'.ready','ready');
|
||||
$start=hrtime(true);
|
||||
try{$receipt=app(App\Services\ReceiptWorkflow::class)->confirm($actor,App\Models\PurchaseOrder::findOrFail($input['order']),$input['payload']);
|
||||
$result=['status'=>200,'receipt_id'=>$receipt->id];
|
||||
}catch(Illuminate\Validation\ValidationException){$result=['status'=>422];}
|
||||
catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$result=['status'=>$e->getStatusCode()];}
|
||||
$result['elapsed_ms']=round((hrtime(true)-$start)/1e6,3);file_put_contents($file.'.result',json_encode($result,JSON_THROW_ON_ERROR));
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/** Runs only inside the existing disposable-database proof, never on an application. */
|
||||
function inboxPgRaces(array $environment, array $config, string $database): array
|
||||
{
|
||||
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', $database) || ($environment['INBOX_TEST_PG'] ?? null) !== '1') {
|
||||
throw new LogicException('Disputas de recepção exigem destino descartável.');
|
||||
}
|
||||
config(['database.connections.inbox_proof_read' => [...$config, 'database' => $database, 'url' => null]]);
|
||||
$reader = DB::connection('inbox_proof_read');
|
||||
$worker = fn (string $mode, int $id = 1) => new Process([PHP_BINARY, 'tests/Concurrency/inbox-worker.php', $mode, (string) $id], dirname(__DIR__, 2), $environment, timeout: 20);
|
||||
$races = [];
|
||||
try {
|
||||
foreach (['same_page', 'different_pages'] as $scenario) {
|
||||
$worker('prepare')->mustRun();
|
||||
$first = $worker('hold');
|
||||
$first->start();
|
||||
$deadline = microtime(true) + 5;
|
||||
while ($first->isRunning() && ! str_contains($first->getOutput(), 'locked') && microtime(true) < $deadline) {
|
||||
usleep(20000);
|
||||
}
|
||||
if (! str_contains($first->getOutput(), 'locked')) {
|
||||
throw new RuntimeException('Primeiro receptor sem bloqueio demonstrado.');
|
||||
}
|
||||
$second = $worker('run', $scenario === 'same_page' ? 1 : 2);
|
||||
$second->start();
|
||||
$blocked = false;
|
||||
$deadline = microtime(true) + 5;
|
||||
while ($first->isRunning() && microtime(true) < $deadline) {
|
||||
$blocked = (bool) $reader->selectOne("SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE datname=? AND wait_event_type='Lock') AS blocked", [$database])->blocked;
|
||||
if ($blocked) {
|
||||
break;
|
||||
}
|
||||
usleep(20000);
|
||||
}
|
||||
$first->wait();
|
||||
$second->wait();
|
||||
if (! $first->isSuccessful() || ! $second->isSuccessful() || ! $blocked) {
|
||||
throw new RuntimeException('Disputa sem execução/bloqueio confirmados.');
|
||||
}
|
||||
$a = json_decode(str_replace('locked', '', $first->getOutput()), true, 16, JSON_THROW_ON_ERROR);
|
||||
$b = json_decode($second->getOutput(), true, 16, JSON_THROW_ON_ERROR);
|
||||
$accepted = ($a['result'] === 'accepted' ? 1 : 0) + ($b['result'] === 'accepted' ? 1 : 0);
|
||||
if ($accepted !== ($scenario === 'same_page' ? 2 : 1)
|
||||
|| ($scenario === 'same_page' && (($a['inserted'] + $b['inserted']) !== 1 || ($a['replayed'] === $b['replayed'])))
|
||||
|| (int) $reader->table('sgc_exchange_facts')->count() !== 1
|
||||
|| (int) $reader->table('sgc_exchange_streams')->value('version') !== 2) {
|
||||
throw new RuntimeException('Disputa duplicou factos ou fez avançar o cursor indevidamente.');
|
||||
}
|
||||
$races[] = ['scenario' => $scenario, 'real_lock_contention' => true, 'facts' => 1, 'checkpoint_version' => 2, 'result' => 'passed'];
|
||||
}
|
||||
$worker('prepare')->mustRun();
|
||||
$interrupted = $worker('exit-after-commit');
|
||||
$interrupted->run();
|
||||
if ($interrupted->getExitCode() !== 42) {
|
||||
throw new RuntimeException('Interrupção após commit não demonstrada.');
|
||||
}
|
||||
$retry = $worker('run');
|
||||
$retry->mustRun();
|
||||
$result = json_decode($retry->getOutput(), true, 16, JSON_THROW_ON_ERROR);
|
||||
if (($result['replayed'] ?? false) !== true || $result['inserted'] !== 0 || (int) $reader->table('sgc_exchange_facts')->count() !== 1
|
||||
|| (int) $reader->table('sgc_exchange_streams')->value('version') !== 2) {
|
||||
throw new RuntimeException('Retry após perda da confirmação não foi idempotente.');
|
||||
}
|
||||
$races[] = ['scenario' => 'process_exit_after_commit_before_acknowledgement', 'result' => 'passed', 'facts' => 1, 'checkpoint_version' => 2];
|
||||
} finally {
|
||||
DB::purge('inbox_proof_read');
|
||||
}
|
||||
|
||||
return $races;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
// Runs only inside the disposable reproduction after its authorised lifecycle.
|
||||
// Inherits the explicit test scope/clients; never creates grants in the host.
|
||||
$catalogue=$call($actor,'/api/accesses');
|
||||
$check($catalogue['status']===200&&in_array('purchasing_requisitions.prepare',$catalogue['data']['catalogue']['unit'],true),'native administration receives complete unit catalogue');
|
||||
$check(($catalogue['data']['catalogue_labels']['purchasing_requisitions.prepare']??'')==='Preparar e submeter requisições','native administration receives canonical server labels');
|
||||
$check($call($head,'/api/accesses')['status']===403,'reviewer cannot inspect administrative catalogue without management right');
|
||||
$check($call($actor,'/api/accesses','POST',['email'=>\App\Models\User::findOrFail($head)->email,'unit_id'=>$scope['unit_id'],'permission'=>'purchasing_budget.consume','reason'=>'Forbidden delegation outside own synthetic authority'])['status']===403,'forged delegation of an unowned catalogue right refused');
|
||||
|
||||
$uiManifest=json_decode(file_get_contents($clone.'/public/build/manifest.json'),true,32,JSON_THROW_ON_ERROR);
|
||||
foreach($uiManifest as $chunk)foreach([$chunk['file'],...($chunk['css']??[])] as $file){
|
||||
$asset=$anonymous->get('http://127.0.0.1:'.$port.'/build/'.$file);
|
||||
$check($asset->getStatusCode()===200&&strlen((string)$asset->getBody())>100,'packaged frontend dependency served before authentication');
|
||||
}
|
||||
$main=$anonymous->get('http://127.0.0.1:'.$port.'/');
|
||||
$check($main->getStatusCode()===200&&str_contains((string)$main->getBody(),'/build/modules/internal-requisitions/assets/'),'native SGF entry renders the packaged frontend manifest');
|
||||
$check($anonymous->get('http://127.0.0.1:'.$port.'/build/modules/internal-requisitions/assets/missing.js')->getStatusCode()===404,'undeclared frontend asset refused');
|
||||
|
||||
// Reproduce destination-style broad inherited ACLs on NEW objects, then prove
|
||||
// the proposed map narrows them without touching any of the 87 prior tables.
|
||||
$beforeAcl=[];
|
||||
foreach($inventory['tables'] as $table)$beforeAcl[$table]=$db->selectOne('SELECT relacl::text AS acl FROM pg_class WHERE oid=?::regclass',['public.'.$table])->acl;
|
||||
foreach(\Tempo\Purchasing\Delivery\SqlPrivileges::TABLES as $table=>$rights)$db->statement('GRANT SELECT,INSERT,UPDATE,DELETE ON TABLE public.'.$q($table).' TO '.$q($runtime));
|
||||
foreach(\Tempo\Purchasing\Delivery\SqlPrivileges::plan($runtime)['statements'] as $sql)$db->statement($sql);
|
||||
$check(!$rt->selectOne("SELECT has_table_privilege(current_user,'cb_centres','UPDATE') AS allowed")->allowed&&!$rt->selectOne("SELECT has_table_privilege(current_user,'cb_audit','DELETE') AS allowed")->allowed,'minimal plan removes inherited update and delete on new append-only sources');
|
||||
$check(!$rt->selectOne("SELECT has_table_privilege(current_user,'ir_requests','TRUNCATE') AS allowed")->allowed,'runtime cannot truncate requisitions');
|
||||
foreach($beforeAcl as $table=>$acl)$check($db->selectOne('SELECT relacl::text AS acl FROM pg_class WHERE oid=?::regclass',['public.'.$table])->acl===$acl,'existing table ACL preserved '.$table);
|
||||
try{$rt->transaction(fn()=>$rt->statement('CREATE TABLE forbidden_runtime_ddl (id integer)'));throw new LogicException('Unexpected runtime DDL');}
|
||||
catch(\Illuminate\Database\QueryException $e){$check($e->errorInfo[0]==='42501','actual runtime CREATE denied');}
|
||||
try{$rt->transaction(fn()=>$rt->statement('ALTER TABLE permission_grants ADD COLUMN forbidden_runtime integer'));throw new LogicException('Unexpected runtime ALTER');}
|
||||
catch(\Illuminate\Database\QueryException $e){$check($e->errorInfo[0]==='42501','actual runtime ALTER of identity catalogue denied');}
|
||||
|
||||
$badCentre=['operation_key'=>(string)\Illuminate\Support\Str::uuid(),'code'=>'NOT-ALLOWED','reason'=>'Validation in the isolated reproduction only'];
|
||||
$check($call($head,'/api/internal-requisitions/centres','POST',$badCentre)['status']===403,'centre creation requires own configuration and budget rights');
|
||||
$check($call($actor,'/api/internal-requisitions/centres','POST',$badCentre+['company_id'=>999999])['status']===422,'centre context cannot be forged');
|
||||
$check(\Illuminate\Support\Facades\DB::table('cb_centres')->count()===1,'refused centre operations leave original source intact');
|
||||
foreach(['equipment','part'] as $unsupported){
|
||||
$r=$call($actor,'/api/internal-requisitions/save','POST',['operation_key'=>(string)\Illuminate\Support\Str::uuid(),'cost_center_id'=>$f['centre'],'priority'=>'normal','needed_by'=>date('Y-m-d',time()+864000),'justification'=>'Unsupported type must remain outside this increment.','items'=>[['description'=>'Unsupported source','category'=>'Excluded','purchase_type'=>$unsupported,'quantity'=>'1.000','unit'=>'UN','unit_price'=>'1.00','tax_rate'=>'0.00']]]);
|
||||
$check($r['status']===422,'unsupported requisition type refused '.$unsupported);
|
||||
}
|
||||
$data=json_decode(\Illuminate\Support\Facades\DB::table('ir_requests')->where('id',$id)->value('data'),true,32,JSON_THROW_ON_ERROR);
|
||||
$check(array_column($data['items'],'purchase_type')===['consumable','service','other'],'approved lifecycle covers the three supported basic types');
|
||||
$check(\Illuminate\Support\Facades\DB::table('ir_requests')->count()===1&&\Illuminate\Support\Facades\DB::table('cb_budget_entries')->where('kind','reserve')->count()===1,'unsupported types create neither a second request nor reservation');
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
// Source inspection and schema-only export. Never exports rows or changes source privileges.
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
$host = getenv('FIX_SOURCE_HOST');
|
||||
$output = getenv('FIX_SCHEMA_FILE');
|
||||
require $host.'/vendor/autoload.php';
|
||||
$app = require $host.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
try {
|
||||
$db = DB::connection();
|
||||
$db->beginTransaction();
|
||||
$db->statement('SET TRANSACTION READ ONLY');
|
||||
$tables = array_column($db->select("select tablename from pg_tables where schemaname='public' order by tablename"), 'tablename');
|
||||
$acl = [];
|
||||
foreach ($tables as $table) {
|
||||
$rights = [];
|
||||
foreach (['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'REFERENCES', 'TRIGGER', 'TRUNCATE'] as $right) {
|
||||
if ($db->selectOne('select has_table_privilege(current_user,?,?) as allowed', ['public.'.$table, $right])->allowed) {
|
||||
$rights[] = $right;
|
||||
}
|
||||
}$acl[$table] = $rights;
|
||||
}
|
||||
$fingerprints = [];
|
||||
foreach (['permission_grants', 'company_memberships', 'identity_audit'] as $table) {
|
||||
$rows = $db->select('select to_jsonb(t)::text as value from "'.$table.'" t order by to_jsonb(t)::text');
|
||||
$fingerprints[$table] = hash('sha256', json_encode(array_column($rows, 'value'), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
$functions = $db->select("select p.oid::regprocedure::text as identity from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='public' and has_function_privilege(current_user,p.oid,'EXECUTE') order by 1");
|
||||
$config = $db->getConfig();
|
||||
$db->rollBack();
|
||||
if (getenv('FIX_CHECK_ONLY') === '1') {
|
||||
echo json_encode(['source_security_fingerprints' => $fingerprints, 'tables' => $tables, 'database' => $config['database'], 'read_only' => true], JSON_THROW_ON_ERROR);
|
||||
exit(0);
|
||||
}
|
||||
$dump = new Process(['C:/Program Files/PostgreSQL/17/bin/pg_dump.exe', '--schema-only', '--format=custom', '--no-owner', '--no-privileges', '--no-comments', '--file='.$output, '--dbname='.$config['database']], null,
|
||||
['PGHOST' => $config['host'], 'PGPORT' => (string) $config['port'], 'PGUSER' => $config['username'], 'PGPASSWORD' => $config['password'], 'PGOPTIONS' => '-c default_transaction_read_only=on'], null, 60);
|
||||
$dump->mustRun();
|
||||
echo json_encode(['database' => $config['database'], 'tables' => $tables, 'runtime_table_acl' => $acl, 'runtime_functions' => array_column($functions, 'identity'), 'source_security_fingerprints' => $fingerprints, 'schema_sha256' => hash_file('sha256', $output), 'rows_exported' => 0, 'read_only' => true], JSON_THROW_ON_ERROR);
|
||||
} catch (Throwable $error) {
|
||||
if (isset($db) && $db->transactionLevel() > 0) {
|
||||
$db->rollBack();
|
||||
}fwrite(STDERR, 'Schema-only export failed: '.get_class($error).($error instanceof QueryException ? ' SQLSTATE '.($error->errorInfo[0] ?? '').'; '.$error->getSql() : '')."\n");
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Providers\AppServiceProvider;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Cookie\CookieJar;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tempo\Purchasing\Delivery\CodeDeployment;
|
||||
use Tempo\Purchasing\Delivery\Installation;
|
||||
use Tempo\Purchasing\Delivery\SqlPrivileges;
|
||||
|
||||
$original = getenv('FIX_SOURCE_HOST');
|
||||
$clone = getenv('FIX_CLONE');
|
||||
$root = getenv('FIX_ROOT');
|
||||
require $clone.'/vendor/autoload.php';
|
||||
$app = require $clone.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$bundle = getenv('FIX_BUNDLE');
|
||||
$work = getenv('FIX_WORK');
|
||||
$runtime = getenv('FIX_RUNTIME');
|
||||
$runtimePassword = getenv('FIX_RUNTIME_PASSWORD');
|
||||
require $bundle.'/integrations/sgf/operational/bootstrap.php';
|
||||
require $bundle.'/integrations/sgf/operational/CodeDeployment.php';
|
||||
$inventory = json_decode(file_get_contents($work.'/baseline.json'), true, 64, JSON_THROW_ON_ERROR);
|
||||
$db = DB::connection();
|
||||
if (! preg_match('/^sgf_reproduction_([a-f0-9]{12})$/D', $db->getDatabaseName(), $identity) || $runtime !== 'fix_app_'.$identity[1] || $db->selectOne('select current_user as name')->name !== 'fix_owner_'.$identity[1]) {
|
||||
throw new RuntimeException('Disposable reproduction identity required before any test grant or write.');
|
||||
}
|
||||
$checks = [];
|
||||
$check = function ($ok, $label) use (&$checks) {
|
||||
if (! $ok) {
|
||||
throw new RuntimeException($label);
|
||||
}$checks[] = $label;
|
||||
};
|
||||
$tables = array_column($db->select("select tablename from pg_tables where schemaname='public' order by tablename"), 'tablename');
|
||||
$check($tables === $inventory['tables'], 'reproduced current 87-table schema without source rows');
|
||||
$check(DB::table('users')->count() === 0 && DB::table('permission_grants')->count() === 0, 'no accounts or grants imported from destination');
|
||||
$q = fn ($name) => '"'.str_replace('"', '""', $name).'"';
|
||||
$db->statement('GRANT USAGE ON SCHEMA public TO '.$q($runtime));
|
||||
foreach ($inventory['runtime_table_acl'] as $table => $rights) {
|
||||
if ($rights) {
|
||||
$db->statement('GRANT '.implode(', ', $rights).' ON TABLE public.'.$q($table).' TO '.$q($runtime));
|
||||
}
|
||||
if (in_array('INSERT', $rights, true) && $db->getSchemaBuilder()->hasColumn($table, 'id')) {
|
||||
$seq = $db->selectOne('select pg_get_serial_sequence(?,?) as name', ['public.'.$table, 'id'])->name;
|
||||
if ($seq) {
|
||||
$db->statement('GRANT USAGE ON SEQUENCE '.$seq.' TO '.$q($runtime));
|
||||
}
|
||||
}
|
||||
}
|
||||
$db->statement('REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM PUBLIC');
|
||||
foreach ($inventory['runtime_functions'] as $function) {
|
||||
$db->statement('GRANT EXECUTE ON FUNCTION '.$function.' TO '.$q($runtime));
|
||||
}
|
||||
config(['database.connections.fixed_runtime' => [...$db->getConfig(), 'url' => null, 'username' => $runtime, 'password' => $runtimePassword]]);
|
||||
$rt = DB::connection('fixed_runtime');
|
||||
$check(! $rt->selectOne("select has_schema_privilege(current_user,'public','CREATE') as allowed")->allowed, 'runtime cannot create schema objects');
|
||||
$now = now();
|
||||
$tenant = DB::table('tenants')->insertGetId(['name' => 'Empresa sintética da reprodução', 'created_at' => $now, 'updated_at' => $now]);
|
||||
$company = DB::table('companies')->insertGetId(['tenant_id' => $tenant, 'name' => 'Horizonte sintético', 'created_at' => $now, 'updated_at' => $now]);
|
||||
$unit = DB::table('units')->insertGetId(['tenant_id' => $tenant, 'company_id' => $company, 'name' => 'Unidade sintética', 'created_at' => $now, 'updated_at' => $now]);
|
||||
$password = bin2hex(random_bytes(24));
|
||||
$actor = User::create(['name' => 'Preparador sintético', 'email' => 'prepare-'.$unit.'@example.test', 'password' => $password])->id;
|
||||
$head = User::create(['name' => 'Aprovador sintético', 'email' => 'approve-'.$unit.'@example.test', 'password' => $password])->id;
|
||||
foreach ([$actor, $head] as $id) {
|
||||
DB::table('company_memberships')->insert(['user_id' => $id, 'tenant_id' => $tenant, 'company_id' => $company]);
|
||||
foreach (['company.view' => null, 'unit.view' => $unit] as $right => $u) {
|
||||
DB::table('permission_grants')->insert(['user_id' => $id, 'tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $u, 'permission' => $right, 'created_at' => $now, 'updated_at' => $now]);
|
||||
}
|
||||
}
|
||||
DB::table('permission_grants')->insert(['user_id' => $actor, 'tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => null, 'permission' => 'access.manage', 'created_at' => $now, 'updated_at' => $now]);
|
||||
$security = function () use ($db) {
|
||||
$out = [];
|
||||
foreach (['users', 'company_memberships', 'permission_grants', 'identity_audit'] as $table) {
|
||||
$out[$table] = hash('sha256', json_encode($db->select('select to_jsonb(t)::text as data from "'.$table.'" t order by to_jsonb(t)::text'), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
return $out;
|
||||
};
|
||||
$before = $security();
|
||||
$target = Installation::target($db);
|
||||
$fault = true;
|
||||
DB::listen(function ($query) use (&$fault) {
|
||||
if ($fault && str_contains($query->sql, 'create table "cb_ui_commands"')) {
|
||||
$fault = false;
|
||||
throw new RuntimeException('Injected corrected migration failure');
|
||||
}
|
||||
});
|
||||
try {
|
||||
Installation::migrate($db, $target, str_repeat('a', 64), 'Isolated migrator');
|
||||
throw new LogicException('Expected migration failure');
|
||||
} catch (RuntimeException$error) {
|
||||
$check($error->getMessage() === 'Injected corrected migration failure', 'injected dependency migration failure');
|
||||
}
|
||||
$check(! $db->getSchemaBuilder()->hasTable('ir_requests') && ! $db->getSchemaBuilder()->hasTable('cb_ui_commands') && $security() === $before, 'failed migration restores original schema and identity data');
|
||||
Installation::migrate($db, $target, str_repeat('a', 64), 'Isolated migrator');
|
||||
$check($security() === $before, 'successful migration preserves existing identity and grants');
|
||||
$check(DB::table('cb_centres')->count() === 0 && DB::table('cb_budgets')->count() === 0, 'installation creates empty centres and allocations');
|
||||
try {
|
||||
$rt->table('sgc_installations')->count();
|
||||
throw new LogicException('Unexpected module access');
|
||||
} catch (QueryException$error) {
|
||||
$check($error->errorInfo[0] === '42501', 'runtime module SQL access denied before test-only privilege plan');
|
||||
}
|
||||
$privileges = SqlPrivileges::plan($runtime);
|
||||
foreach ($privileges['statements'] as $sql) {
|
||||
$db->statement($sql);
|
||||
}
|
||||
$check(! $rt->selectOne("select has_table_privilege(current_user,'ir_events','UPDATE') as allowed")->allowed && ! $rt->selectOne("select has_table_privilege(current_user,'ir_requests','DELETE') as allowed")->allowed, 'minimal runtime excludes historical update and requisition deletion');
|
||||
$check($rt->table('sgc_installations')->value('revision') === 2, 'runtime reads corrected revision without ownership');
|
||||
$deploy = new CodeDeployment($clone);
|
||||
$mh = hash_file('sha256', $bundle.'/manifest.json');
|
||||
$plan = $deploy->plan($bundle, $mh);
|
||||
$applied = $deploy->apply($bundle, $mh, $plan['target_sha256'], 'Isolated code preparation', true);
|
||||
unlink($clone.'/storage/framework/down');
|
||||
file_put_contents($clone.'/config/purchasing-operational.php', '<?php return ["enabled"=>true,"target"=>getenv("IR_HTTP_TARGET"),"scopes"=>[getenv("IR_HTTP_SCOPE")]];');
|
||||
file_put_contents($clone.'/config/purchasing-access.php', '<?php return ["initialisation_scopes"=>[getenv("IR_HTTP_SCOPE")]];');
|
||||
$f = ['actor' => $actor, 'validator' => $head, 'scope' => ['tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $unit]];
|
||||
$scope = $f['scope'];
|
||||
$target = Installation::target(DB::connection());
|
||||
$scopeKey = implode(':', $scope);
|
||||
config(['purchasing-operational.enabled' => true, 'purchasing-operational.target' => $target, 'purchasing-operational.scopes' => [$scopeKey]]);
|
||||
$actor = $f['actor'];
|
||||
$head = $f['validator'];
|
||||
DB::table('permission_grants')->where($scope)->where('user_id', $actor)->where('permission', 'like', 'purchasing_%')->delete();
|
||||
$password = bin2hex(random_bytes(24));
|
||||
foreach ([$actor, $head] as $id) {
|
||||
$u = User::findOrFail($id);
|
||||
$u->password = $password;
|
||||
$u->save();
|
||||
}
|
||||
$socket = stream_socket_server('tcp://127.0.0.1:0');
|
||||
$address = stream_socket_get_name($socket, false);
|
||||
fclose($socket);
|
||||
$port = (int) substr(strrchr($address, ':'), 1);
|
||||
$server = new Process([PHP_BINARY, '-S', '127.0.0.1:'.$port, '-t', $clone.'/public', $root.'/tests/Support/sgf-composition-router.php'], $clone, ['IR_CLONE' => $clone, 'DB_USERNAME' => $runtime, 'DB_PASSWORD' => $runtimePassword, 'IR_HTTP_ENABLED' => '1', 'IR_HTTP_TARGET' => $target, 'IR_HTTP_SCOPE' => $scopeKey], null, 180);
|
||||
$server->start();
|
||||
$earlyChecks = $checks;
|
||||
$check = function ($ok, $label) use (&$checks) {
|
||||
if (! $ok) {
|
||||
throw new RuntimeException($label);
|
||||
}$checks[] = $label;
|
||||
};
|
||||
$check(str_starts_with(str_replace('\\', '/', (new ReflectionClass(AppServiceProvider::class))->getFileName()), str_replace('\\', '/', $clone).'/app/'), 'provider really loaded from isolated copy');
|
||||
$plan = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $original.'/scripts/sgf.ps1', 'php', $clone.'/packages/internal-requisitions/scripts/sgf-internal-install.php', 'plan', $clone], $clone, null, null, 60);
|
||||
$plan->mustRun();
|
||||
$check(json_decode($plan->getOutput(), true, 16, JSON_THROW_ON_ERROR)['schema_prepared'], 'new bundle bootstrap accepted by installer integrity check');
|
||||
try {
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
if (@fsockopen('127.0.0.1', $port)) {
|
||||
break;
|
||||
}usleep(50000);
|
||||
}
|
||||
$clients = [];
|
||||
$anonymous = new Client(['http_errors' => false]);
|
||||
$check($anonymous->get('http://127.0.0.1:'.$port.'/purchasing/internal-requisitions/access-initialisation', ['headers' => ['Accept' => 'application/json']])->getStatusCode() === 401, 'anonymous direct initialisation URL denied');
|
||||
$call = function ($id, $path, $method = 'GET', $data = null, $headers = []) use (&$clients, $port) {
|
||||
$c = &$clients[$id];
|
||||
$opts = ['http_errors' => false, 'headers' => array_merge(['Accept' => 'application/json', 'X-CSRF-TOKEN' => $c['csrf'], 'X-SGF-Context' => $c['token'] ?? '', 'X-Purchasing-Context' => 'procurement.internal'], $headers)];
|
||||
if ($data !== null) {
|
||||
$opts['json'] = $data;
|
||||
}
|
||||
$r = $c['http']->request($method, 'http://127.0.0.1:'.$port.$path, $opts);
|
||||
$body = (string) $r->getBody();
|
||||
|
||||
return ['status' => $r->getStatusCode(), 'data' => json_decode($body, true), 'body' => $body];
|
||||
};
|
||||
foreach ([$actor, $head] as $id) {
|
||||
$http = new Client(['cookies' => new CookieJar, 'timeout' => 15]);
|
||||
$s = json_decode((string) $http->get('http://127.0.0.1:'.$port.'/api/session', ['headers' => ['Accept' => 'application/json']])->getBody(), true);
|
||||
$clients[$id] = ['http' => $http, 'csrf' => $s['csrf']];
|
||||
$login = $call($id, '/api/login', 'POST', ['email' => User::findOrFail($id)->email, 'password' => $password]);
|
||||
$check($login['status'] === 200, 'native clone authentication '.$id);
|
||||
$clients[$id]['csrf'] = $login['data']['csrf'];
|
||||
$selected = $call($id, '/api/context', 'POST', $scope);
|
||||
$check($selected['status'] === 200, 'native clone context '.$id);
|
||||
$clients[$id]['token'] = $selected['data']['context']['token'];
|
||||
}
|
||||
$page = $call($actor, '/purchasing/internal-requisitions/access-initialisation');
|
||||
$check($page['status'] === 200 && str_contains($page['body'], 'Administrador nominativo'), 'provider registers initial authority page without prior functional rights');
|
||||
$initial = ['operation_key' => (string) Str::uuid(), 'reason' => 'Entrega sintética através da composição HTTP.', 'administrators' => [['user_id' => $actor, 'rights' => ['purchasing_requisitions.view', 'purchasing_requisitions.prepare', 'purchasing_requisitions.configure', 'purchasing_requisitions.approve.department_head', 'purchasing_budget.record', 'purchasing_budget.view', 'purchasing_budget.create', 'purchasing_budget.approve']]]];
|
||||
$check($call($actor, '/api/internal-requisitions/access-initialisation', 'POST', $initial, ['X-CSRF-TOKEN' => 'bad'])['status'] === 419, 'initial authority CSRF enforced');
|
||||
$check($call($actor, '/api/internal-requisitions/access-initialisation', 'POST', $initial)['status'] === 200, 'initial authority through normal middleware');
|
||||
$check($call($actor, '/purchasing/internal-requisitions')['status'] === 409, 'work page requires explicit module selection');
|
||||
$oldToken = $clients[$actor]['token'];
|
||||
$selected = $call($actor, '/api/context', 'POST', $scope + ['module_id' => 'purchasing']);
|
||||
$check($selected['status'] === 200, 'new requisition view right makes purchasing selectable');
|
||||
$clients[$actor]['token'] = $selected['data']['context']['token'];
|
||||
$check($call($actor, '/api/internal-requisitions', 'GET', null, ['X-SGF-Context' => $oldToken])['status'] === 409, 'stale module generation refused');
|
||||
$check($call($actor, '/purchasing/internal-requisitions')['status'] === 200, 'provider registers operational work page');
|
||||
$check($call($actor, '/api/internal-requisitions', 'GET', null, ['X-Purchasing-Context' => 'procurement.third_party'])['status'] === 403, 'different enterprise option refused by operational composition');
|
||||
foreach (['purchasing_requisitions.view', 'purchasing_requisitions.approve.department_head', 'purchasing_budget.view', 'purchasing_budget.approve'] as $right) {
|
||||
$r = $call($actor, '/api/accesses', 'POST', ['email' => User::findOrFail($head)->email, 'unit_id' => $scope['unit_id'], 'permission' => $right, 'reason' => 'Atribuição sintética explícita.']);
|
||||
$check($r['status'] === 200, 'native administrative delegation '.$right);
|
||||
}
|
||||
$selected = $call($head, '/api/context', 'POST', $scope + ['module_id' => 'purchasing']);
|
||||
$clients[$head]['token'] = $selected['data']['context']['token'];
|
||||
$check($call($actor, '/purchasing/internal-requisitions/budget-setup')['status'] === 200, 'empty installation exposes authorised centre and budget preparation');
|
||||
$centreCommand = ['operation_key' => (string) Str::uuid(), 'code' => 'SINTETICO-REPRO', 'reason' => 'Centro exclusivamente sintético da reprodução isolada.'];
|
||||
$centre = $call($actor, '/api/internal-requisitions/centres', 'POST', $centreCommand);
|
||||
$check($centre['status'] === 200, 'centre created through authorised UI API with runtime role');
|
||||
$f['centre'] = $centre['data']['centre_id'];
|
||||
$repeat = $call($actor, '/api/internal-requisitions/centres', 'POST', $centreCommand);
|
||||
$check($repeat['status'] === 200 && $repeat['data']['replayed'] && DB::table('cb_centres')->count() === 1, 'centre retries preserve one source');
|
||||
$budgetData = ['cost_center_id' => $f['centre'], 'year' => (int) date('Y'), 'kind' => 'operating', 'includes_tax' => true, 'amount' => '10000.00', 'reason' => 'Dotação exclusivamente sintética, sem valores de destino.'];
|
||||
$prepared = $call($actor, '/api/purchasing-budget/create', 'POST', $budgetData, ['Idempotency-Key' => (string) Str::uuid()]);
|
||||
$check($prepared['status'] === 200, 'existing budget API prepares synthetic allocation');
|
||||
$budgetId = DB::table('cb_budgets')->value('id');
|
||||
$check($call($actor, '/api/purchasing-budget/approve', 'POST', ['budget_id' => $budgetId], ['Idempotency-Key' => (string) Str::uuid()])['status'] !== 200, 'budget approval requires independent identity');
|
||||
$approvedBudget = $call($head, '/api/purchasing-budget/approve', 'POST', ['budget_id' => $budgetId], ['Idempotency-Key' => (string) Str::uuid()]);
|
||||
$check($approvedBudget['status'] === 200, 'independent allocation approval uses existing budget kernel');
|
||||
$list = $call($actor, '/api/purchasing-budget');
|
||||
$check($list['status'] === 200 && count($list['data']['centres']) === 1, 'workspace dependencies available on reproduced schema');
|
||||
$check($call($actor, '/api/purchasing-budget/movement', 'POST', [], ['Idempotency-Key' => (string) Str::uuid()])['status'] === 403, 'direct budget movements cannot bypass requisition lifecycle');
|
||||
$policy = ['version' => 0, 'reason' => 'Política sintética na composição integrada.', 'enabled' => true, 'budget_required' => true, 'first_limit_minor' => 1000000, 'second_limit_minor' => 2000000, 'approval_includes_tax' => true, 'centres' => [['id' => $f['centre'], 'department_ref' => 'OPER']], 'members' => [['user_id' => $actor, 'department_ref' => 'OPER'], ['user_id' => $head, 'department_ref' => 'OPER']]];
|
||||
$check($call($actor, '/api/internal-requisitions/policy', 'POST', $policy)['status'] === 200, 'operational policy configured via HTTP');
|
||||
$draft = $call($actor, '/api/internal-requisitions/save', 'POST', ['operation_key' => (string) Str::uuid(), 'cost_center_id' => $f['centre'], 'priority' => 'normal', 'needed_by' => date('Y-m-d', time() + 864000), 'justification' => 'Requisição sintética da composição.', 'items' => [['description' => 'Material', 'category' => 'Consumíveis', 'purchase_type' => 'consumable', 'quantity' => '2.000', 'unit' => 'UN', 'unit_price' => '50.00', 'tax_rate' => '16.00'], ['description'=>'Servi?o sint?tico','category'=>'Servi?os','purchase_type'=>'service','quantity'=>'1.000','unit'=>'UN','unit_price'=>'25.00','tax_rate'=>'0.00'], ['description'=>'Outro sint?tico','category'=>'Outros','purchase_type'=>'other','quantity'=>'1.000','unit'=>'UN','unit_price'=>'10.00','tax_rate'=>'0.00']]]);
|
||||
$check($draft['status'] === 200, 'integrated operational draft');
|
||||
$id = $draft['data']['id'];
|
||||
$submitted = $call($actor, "/api/internal-requisitions/$id/submit", 'POST', ['operation_key' => (string) Str::uuid(), 'version' => 1]);
|
||||
$check($submitted['status'] === 200 && DB::table('cb_budget_entries')->where('kind', 'reserve')->count() === 1, 'integrated submit uses existing budget adapter');
|
||||
$decision = ['operation_key' => (string) Str::uuid(), 'version' => $submitted['data']['version'], 'decision' => 'approved'];
|
||||
$check($call($actor, "/api/internal-requisitions/$id/decide", 'POST', $decision)['status'] === 403, 'integrated independent identity enforced');
|
||||
$approved = $call($head, "/api/internal-requisitions/$id/decide", 'POST', $decision);
|
||||
$check($approved['status'] === 200 && $approved['data']['status'] === 'approved', 'integrated approval by another identity');
|
||||
$view = $call($actor, "/api/internal-requisitions/$id");
|
||||
$check($view['status'] === 200 && count($view['data']['history']) === 3, 'integrated authorised consultation and history');
|
||||
require __DIR__.'/sgf-admin-candidate-checks.php';
|
||||
$revoke = $call($actor, '/api/accesses/revoke', 'POST', ['email' => User::findOrFail($head)->email, 'unit_id' => $scope['unit_id'], 'permission' => 'purchasing_requisitions.view', 'reason' => 'Revogação sintética explícita.']);
|
||||
$check($revoke['status'] === 200 && $call($head, "/api/internal-requisitions/$id")['status'] === 403, 'native revocation applies to direct module URL');
|
||||
file_put_contents($clone.'/config/modules.php', '<?php return ["contracted"=>[]];');
|
||||
// Deployment configuration changes require restarting persistent PHP services.
|
||||
$server->stop(1);
|
||||
$server->start();
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
if (@fsockopen('127.0.0.1', $port)) {
|
||||
break;
|
||||
}
|
||||
usleep(50000);
|
||||
}
|
||||
$check($call($actor, "/api/internal-requisitions/$id")['status'] === 403, 'uncontracted module refused at server');
|
||||
file_put_contents($clone.'/config/modules.php', '<?php return ["contracted"=>["purchasing"]];');
|
||||
file_put_contents($clone.'/config/purchasing-operational.php', '<?php return ["enabled"=>false];');
|
||||
$server->stop(1);
|
||||
$server->start();
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
if (@fsockopen('127.0.0.1', $port)) {
|
||||
break;
|
||||
}usleep(50000);
|
||||
}
|
||||
$check($call($actor, '/purchasing/internal-requisitions')['status'] === 404, 'disabled composition exposes no executable work page');
|
||||
$fingerprints = [];
|
||||
foreach (array_column($db->select("select tablename from pg_tables where schemaname='public' order by tablename"), 'tablename') as $table) {
|
||||
$rows = $db->select('select to_jsonb(t)::text as data from "'.$table.'" t order by to_jsonb(t)::text');
|
||||
$fingerprints[$table] = hash('sha256', json_encode(array_column($rows, 'data'), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
$backup = new Process(['C:/Program Files/PostgreSQL/17/bin/pg_dump.exe', '--format=custom', '--file='.$work.'/installed.dump', '--dbname='.$db->getDatabaseName()], null, ['PGHOST' => $db->getConfig('host'), 'PGPORT' => (string) $db->getConfig('port'), 'PGUSER' => $db->getConfig('username'), 'PGPASSWORD' => $db->getConfig('password')], null, 60);
|
||||
$backup->mustRun();
|
||||
file_put_contents($clone.'/storage/framework/down', '{"status":503}');
|
||||
$server->stop(1);
|
||||
require __DIR__.'/sgf-corrected-update.php';
|
||||
$deploy->rollback($applied['deployment_id'], 'Isolated corrected code recovery', true);
|
||||
$check(DB::table('ir_requests')->where('id', $id)->value('status') === 'approved' && DB::table('cb_budget_entries')->where('kind', 'reserve')->count() === 1, 'code recovery preserves submitted origin decision and reservation');
|
||||
$check(! is_dir($clone.'/packages/internal-requisitions') && is_file($clone.'/storage/framework/down'), 'code recovery leaves baseline and maintenance intact');
|
||||
echo json_encode(['passed' => true, 'checks' => $checks, 'clone' => $clone, 'data_fingerprints' => $fingerprints, 'destination_activated' => false, 'normal_provider_routes' => true, 'browser_used' => false], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
} finally {
|
||||
$server->stop(1);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Purchasing\Delivery\Installation;
|
||||
use Tempo\Purchasing\Delivery\SqlPrivileges;
|
||||
|
||||
require getenv('FIX_CLONE').'/vendor/autoload.php';
|
||||
$app = require getenv('FIX_CLONE').'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$bundle = getenv('FIX_BUNDLE');
|
||||
require $bundle.'/integrations/sgf/operational/bootstrap.php';
|
||||
$db = DB::connection();
|
||||
$runtime = getenv('FIX_RUNTIME');
|
||||
if (! preg_match('/^sgf_reproduction_restore_[a-f0-9]{12}$/D', $db->getDatabaseName()) || ! preg_match('/^fix_app_[a-f0-9]{12}$/D', $runtime)) {
|
||||
throw new RuntimeException('Isolated recovery target required.');
|
||||
}
|
||||
$baseline = json_decode(file_get_contents(getenv('FIX_WORK').'/baseline.json'), true, 64, JSON_THROW_ON_ERROR);
|
||||
$q = fn ($name) => '"'.str_replace('"', '""', $name).'"';
|
||||
$db->statement('GRANT USAGE ON SCHEMA public TO '.$q($runtime));
|
||||
foreach ($baseline['runtime_table_acl'] as $table => $rights) {
|
||||
if ($rights) {
|
||||
$db->statement('GRANT '.implode(', ', $rights).' ON TABLE public.'.$q($table).' TO '.$q($runtime));
|
||||
}
|
||||
if (in_array('INSERT', $rights, true) && $db->getSchemaBuilder()->hasColumn($table, 'id')) {
|
||||
$seq = $db->selectOne('select pg_get_serial_sequence(?,?) as name', ['public.'.$table, 'id'])->name;
|
||||
if ($seq) {
|
||||
$db->statement('GRANT USAGE ON SEQUENCE '.$seq.' TO '.$q($runtime));
|
||||
}
|
||||
}
|
||||
}
|
||||
$db->statement('REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM PUBLIC');
|
||||
foreach ($baseline['runtime_functions'] as $function) {
|
||||
$db->statement('GRANT EXECUTE ON FUNCTION '.$function.' TO '.$q($runtime));
|
||||
}
|
||||
foreach (SqlPrivileges::plan($runtime)['statements'] as $sql) {
|
||||
$db->statement($sql);
|
||||
}
|
||||
$expected = json_decode(file_get_contents(getenv('FIX_WORK').'/expected.json'), true, 64, JSON_THROW_ON_ERROR);
|
||||
$fingerprints = [];
|
||||
foreach (array_keys($expected) as $table) {
|
||||
$rows = $db->select('select to_jsonb(t)::text as data from "'.$table.'" t order by to_jsonb(t)::text');
|
||||
$fingerprints[$table] = hash('sha256', json_encode(array_column($rows, 'data'), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
if ($fingerprints !== $expected) {
|
||||
throw new RuntimeException('Recovered data differs.');
|
||||
}
|
||||
config(['database.connections.recovered_runtime' => [...$db->getConfig(), 'username' => $runtime, 'password' => getenv('FIX_RUNTIME_PASSWORD'), 'url' => null]]);
|
||||
$rt = DB::connection('recovered_runtime');
|
||||
if ($rt->table('ir_requests')->value('status') !== 'approved' || $rt->table('cb_budget_entries')->where('kind', 'reserve')->count() !== 1) {
|
||||
throw new RuntimeException('Recovered runtime cannot consult original decision or reservation.');
|
||||
}
|
||||
if ($rt->selectOne("select has_schema_privilege(current_user,'public','CREATE') as allowed")->allowed) {
|
||||
throw new RuntimeException('Recovery escalated runtime role.');
|
||||
}
|
||||
$receipt = $rt->table('sgc_installations')->first();
|
||||
if ($receipt->schema_signature !== Installation::signature($rt, Installation::tables())) {
|
||||
throw new RuntimeException('Recovered schema differs.');
|
||||
}
|
||||
echo json_encode(['verified' => true, 'tables_reconciled' => count($fingerprints), 'approved_origin_reservation_versions_preserved' => true, 'runtime_read_verified' => true, 'runtime_ddl_denied' => true, 'schema_and_triggers_verified' => true, 'sql_acl_reapplied_only_to_isolated_role' => true], JSON_THROW_ON_ERROR);
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
// Code-only successor for the disposable reproduction, never a release candidate.
|
||||
$variant = $work.'/code-only-successor';
|
||||
$vm = json_decode(file_get_contents($bundle.'/manifest.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
foreach ([...array_keys($vm['support_files']), 'manifest.json'] as $file) {
|
||||
if (! is_dir(dirname($variant.'/'.$file))) mkdir(dirname($variant.'/'.$file), 0700, true);
|
||||
copy($bundle.'/'.$file, $variant.'/'.$file);
|
||||
}
|
||||
$file = 'integrations/sgf/internal-requisitions/screen.php';
|
||||
file_put_contents($variant.'/'.$file, "\n<!-- isolated code-only successor -->\n", FILE_APPEND);
|
||||
$vm['support_files'][$file] = hash_file('sha256', $variant.'/'.$file);
|
||||
file_put_contents($variant.'/manifest.json', json_encode($vm, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
$vh = hash_file('sha256', $variant.'/manifest.json');
|
||||
$vp = $deploy->plan($variant, $vh);
|
||||
$vu = $deploy->apply($variant, $vh, $vp['target_sha256'], 'Isolated code-only update', true);
|
||||
$check(hash_file('sha256', $clone.'/packages/internal-requisitions/manifest.json') === $vh, 'code-only successor installed over existing provenance');
|
||||
$deploy->rollback($vu['deployment_id'], 'Isolated successor recovery', true);
|
||||
$check(hash_file('sha256', $clone.'/packages/internal-requisitions/manifest.json') === hash_file('sha256', $bundle.'/manifest.json'), 'code-only successor rollback restores exact candidate');
|
||||
foreach ($fingerprints as $table => $expected) {
|
||||
$rows = $db->select('select to_jsonb(t)::text as data from "'.$table.'" t order by to_jsonb(t)::text');
|
||||
if (hash('sha256', json_encode(array_column($rows, 'data'), JSON_THROW_ON_ERROR)) !== $expected) throw new RuntimeException('Update changed '.$table);
|
||||
}
|
||||
$check(true, 'code update and rollback preserve every reproduced data table');
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
$host=getenv('SOURCING_CLONE');$work=getenv('SOURCING_WORK');require $host.'/vendor/autoload.php';$app=require $host.'/bootstrap/app.php';$app->make(Kernel::class)->bootstrap();
|
||||
try{
|
||||
$db=DB::connection();if(!preg_match('/^sgf_sourcing_test_[a-f0-9]{12}$/D',$db->getDatabaseName()))throw new RuntimeException('Copy only');
|
||||
$fixture=json_decode(file_get_contents($work.'/fixture.json'),true);$command=$argv[1];
|
||||
if(in_array($command,['revoke-evaluate','restore-evaluate','revoke-propose','restore-propose','revoke-view','restore-view'])){
|
||||
$right=str_contains($command,'evaluate')?'purchasing_sourcing.evaluate':(str_contains($command,'propose')?'purchasing_sourcing.propose':'purchasing_requisitions.view');$id=str_contains($command,'evaluate')?$fixture['user_id']:(str_contains($command,'propose')?2:3);
|
||||
$scope=['user_id'=>$id,'tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'permission'=>$right];
|
||||
if(str_starts_with($command,'revoke'))$db->table('permission_grants')->where($scope)->delete();else $db->table('permission_grants')->insertOrIgnore($scope+['created_at'=>now(),'updated_at'=>now()]);
|
||||
}elseif($command==='fail-on')file_put_contents($host.'/storage/framework/sourcing-failure.fixture','isolated failure injection');
|
||||
elseif($command==='fail-off')unlink($host.'/storage/framework/sourcing-failure.fixture');
|
||||
else throw new RuntimeException('Unsupported fixture control');
|
||||
echo json_encode(['ok'=>true]);
|
||||
}catch(Throwable $e){fwrite(STDERR,$e->getMessage());exit(1);}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\User;
|
||||
use Tempo\Purchasing\SgfSourcing\Schema;
|
||||
use Tempo\Purchasing\Delivery\CodeDeployment;
|
||||
$host=getenv('SOURCING_CLONE');$root=getenv('SOURCING_REPO');$work=getenv('SOURCING_WORK');
|
||||
require $host.'/vendor/autoload.php';$app=require $host.'/bootstrap/app.php';$app->make(Kernel::class)->bootstrap();
|
||||
try{
|
||||
$db=DB::connection();$name=$db->selectOne('select current_database() as db')->db;
|
||||
if(!preg_match('/^sgf_sourcing_test_[a-f0-9]{12}$/D',$name)||$name!==getenv('SOURCING_DB')||!str_contains(str_replace('\\','/',$host),'/private/sourcing-')||str_replace('\\','/',$app->basePath())!==$host)throw new RuntimeException('Not an isolated sourcing copy');
|
||||
$candidate=json_decode(file_get_contents($root.'/docs/proofs/sgf-sourcing-candidate.json'),true,32,JSON_THROW_ON_ERROR);
|
||||
if(!class_exists(Schema::class))require_once $candidate['directory'].'/integrations/sgf/sourcing/Schema.php';require_once $root.'/integrations/sgf/operational/CodeDeployment.php';
|
||||
$deploy=new CodeDeployment($host);$checks=[];$check=function($ok,$label)use(&$checks){if(!$ok)throw new RuntimeException($label);$checks[]=$label;};
|
||||
$fingerprint=function(string $table)use($db){return hash('sha256',json_encode($db->table($table)->orderBy('id')->get(),JSON_THROW_ON_ERROR));};
|
||||
if(($argv[1]??'')==='setup'){
|
||||
$baseline=['request'=>$fingerprint('ir_requests'),'budget'=>$fingerprint('cb_budget_entries'),'consumption'=>$fingerprint('cb_budget_consumptions'),'grants_count'=>$db->table('permission_grants')->count()];file_put_contents($work.'/origin.json',json_encode($baseline));
|
||||
$armed=true;$db->listen(function($query)use(&$armed){if($armed&&str_contains(strtolower($query->sql),'create table "ss_events"')){$armed=false;throw new RuntimeException('Injected migration failure');}});
|
||||
try{Schema::migrate($db,hash_file('sha256',$work.'/before.dump'),'Isolated failure proof');throw new RuntimeException('Expected failure');}catch(RuntimeException $e){$check($e->getMessage()==='Injected migration failure','Injected migration failure surfaced');}
|
||||
$check(!$db->getSchemaBuilder()->hasTable('ss_suppliers')&&!$db->table('sgc_installations')->where('module','sourcing')->exists(),'Migration failure rolls back tables and catalogue');
|
||||
$migration=Schema::migrate($db,hash_file('sha256',$work.'/before.dump'),'Isolated sourcing proof');$check($migration['prepared'],'Additive migration accepted');$check(Schema::migrate($db,hash_file('sha256',$work.'/before.dump'),'Isolated replay')['replayed'],'Migration replay is stable');
|
||||
$old=json_decode(file_get_contents($host.'/packages/internal-requisitions/manifest.json'),true);$next=json_decode(file_get_contents($candidate['directory'].'/manifest.json'),true);
|
||||
$current=json_decode(file_get_contents($host.'/storage/app/private/module-deployments/current.json'),true);
|
||||
try{$deploy->plan($candidate['directory'],$candidate['archive']['manifest_sha256']);throw new RuntimeException('Unprepared plan accepted');}catch(RuntimeException $e){$check(str_contains($e->getMessage(),'migração própria'),'Schema-changing update refuses missing migration receipt');}
|
||||
$receipt=['from_manifest_sha256'=>$current['manifest_sha256'],'to_manifest_sha256'=>$candidate['archive']['manifest_sha256'],'host'=>$host,'schema_verified'=>Schema::compatible($db),'changes'=>[]];
|
||||
foreach($next['support_files'] as $file=>$hash)if(($old['support_files'][$file]??null)!==$hash)$receipt['changes'][$file]=['before'=>$old['support_files'][$file]??null,'after'=>$hash];
|
||||
file_put_contents($host.'/storage/framework/down','{}');
|
||||
$cli=new Symfony\Component\Process\Process([PHP_BINARY,$candidate['directory'].'/scripts/sgf-sourcing-install.php','migrate',$host,Tempo\Purchasing\Delivery\Installation::target($db),$work.'/before.dump',hash_file('sha256',$work.'/before.dump'),'Isolated migration CLI',$candidate['archive']['manifest_sha256']],$host,['PG_RESTORE_BINARY'=>'C:/Program Files/PostgreSQL/17/bin/pg_restore.exe']);$cli->mustRun();
|
||||
$cliResult=json_decode($cli->getOutput(),true,32,JSON_THROW_ON_ERROR);$check($cliResult['prepared']&&!$cliResult['activated']&&!$cliResult['rights_granted'],'Packaged migration CLI produces exact receipt without activation or grants');
|
||||
$plan=$deploy->plan($candidate['directory'],$candidate['archive']['manifest_sha256']);$applied=$deploy->apply($candidate['directory'],$candidate['archive']['manifest_sha256'],$plan['target_sha256'],'Isolated sourcing update',true);unlink($host.'/storage/framework/down');file_put_contents($work.'/applied.json',json_encode($applied));
|
||||
$target=Tempo\Purchasing\Delivery\Installation::target($db);file_put_contents($host.'/config/purchasing-operational.php','<?php return '.var_export(['enabled'=>true,'target'=>$target,'scopes'=>['1:1:1']],true).';');file_put_contents($host.'/config/purchasing-sourcing.php','<?php return ["enabled"=>true];');
|
||||
$private=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/development.json'),true);
|
||||
$user=User::forceCreate(['name'=>'Synthetic isolated evaluator','email'=>'fixture-'.Str::uuid().'@example.invalid','password'=>$private['password'],'auth_version'=>1]);
|
||||
$db->table('company_memberships')->insert(['user_id'=>$user->id,'tenant_id'=>1,'company_id'=>1]);
|
||||
$departmentPolicy=$db->table('ir_policies')->where(['tenant_id'=>1,'company_id'=>1,'unit_id'=>1])->first();$departmentPayload=json_decode($departmentPolicy->payload,true);$departmentPayload['members'][]=['user_id'=>$user->id,'department_ref'=>'OPER'];$db->table('ir_policies')->where('id',$departmentPolicy->id)->update(['payload'=>json_encode($departmentPayload)]); // Disposable configuration fixture only; submitted request snapshot is unchanged.
|
||||
$grants=[1=>['purchasing_sourcing.prepare'],2=>['purchasing_sourcing.evaluate','purchasing_sourcing.propose'],$user->id=>['company.view','unit.view','purchasing_requisitions.view','purchasing_sourcing.evaluate','purchasing_sourcing.confirm']];
|
||||
foreach($grants as $id=>$rights)foreach($rights as $right)$db->table('permission_grants')->insert(['user_id'=>$id,'tenant_id'=>1,'company_id'=>1,'unit_id'=>$right==='company.view'?null:1,'permission'=>$right,'created_at'=>now(),'updated_at'=>now()]);
|
||||
foreach([1,2,3] as $n)$db->table('ss_suppliers')->insert(['tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'reference'=>'SYNTHETIC-'.$n,'payload'=>json_encode(['name'=>'Fornecedor sintético '.$n,'active'=>true,'blocked'=>false,'review_until'=>'2027-12-31','version'=>1,'synthetic_only'=>true]),'created_at'=>now()]);
|
||||
file_put_contents($work.'/fixture.json',json_encode(['user_id'=>$user->id,'email'=>$user->email,'password'=>$private['password']]));
|
||||
echo json_encode(['passed'=>true,'checks'=>$checks,'fixture_user_only_in_disposable_copy'=>true,'deployment'=>$applied]);
|
||||
}elseif(($argv[1]??'')==='verify'){
|
||||
$baseline=json_decode(file_get_contents($work.'/origin.json'),true);foreach(['request'=>'ir_requests','budget'=>'cb_budget_entries','consumption'=>'cb_budget_consumptions'] as $key=>$table)$check($baseline[$key]===$fingerprint($table),'Origin '.$key.' unchanged');
|
||||
$check($db->table('ss_processes')->count()===1,'Exactly one sourcing process');$check($db->table('ss_commands')->count()===$db->table('ss_events')->count(),'One event per committed command');
|
||||
$saved=$db->table('ss_processes')->first();$state=json_decode($saved->payload,true);$check($state['confirmed']!==null,'Independent award confirmed in isolated copy');
|
||||
$before=$fingerprint('ss_events');$failed=false;try{$db->transaction(fn()=>$db->table('ss_events')->where('id',1)->update(['action'=>'tampered']));}catch(Throwable){$failed=true;}$check($failed&&$before===$fingerprint('ss_events'),'Database rejects event tampering');
|
||||
$applied=json_decode(file_get_contents($work.'/applied.json'),true);file_put_contents($host.'/storage/framework/down','{}');$rolled=$deploy->rollback($applied['deployment_id'],'Isolated sourcing code recovery',true);
|
||||
$check($state===json_decode($db->table('ss_processes')->first()->payload,true),'Code rollback preserves award data');
|
||||
echo json_encode(['passed'=>true,'checks'=>$checks,'code_recovery'=>$rolled,'old_code_requires_schema_recovery'=>true]);
|
||||
}else throw new RuntimeException('Use setup/verify');
|
||||
}catch(Throwable $e){fwrite(STDERR,get_class($e).': '.$e->getMessage()."\n");exit(1);}
|
||||
@@ -0,0 +1,55 @@
|
||||
import fs from 'node:fs';import assert from 'node:assert/strict';import crypto from 'node:crypto';import {spawn,spawnSync} from 'node:child_process';
|
||||
const repo=process.env.SOURCING_REPO,work=process.env.SOURCING_WORK,host=process.env.SOURCING_CLONE,base='http://127.0.0.1:'+process.env.SOURCING_PORT,php='C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe';
|
||||
assert(/^sgf_sourcing_test_[a-f0-9]{12}$/.test(process.env.SOURCING_DB));
|
||||
const privateData=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/development.json','utf8')),fixture=JSON.parse(fs.readFileSync(work+'/fixture.json','utf8'));
|
||||
const clients={},checks=[];
|
||||
for(const p of [...privateData.profiles,{role:'decisor',email:fixture.email}]){
|
||||
const jar=new Map();let csrf='',token='';
|
||||
const api=async(path,method='GET',body,status=200,override={})=>{
|
||||
const r=await fetch(base+path,{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,'X-Purchasing-Context':'procurement.internal',...override},...(body===undefined?{}:{body:JSON.stringify(body)})});
|
||||
for(const cookie of r.headers.getSetCookie()){const pair=cookie.split(';')[0],at=pair.indexOf('=');jar.set(pair.slice(0,at),pair.slice(at+1));}const text=await r.text();let d;try{d=JSON.parse(text);}catch{d={message:text.slice(0,120)};}
|
||||
assert.equal(r.status,status,`${p.role} ${path}: ${d.message??JSON.stringify(d.errors)}`);if(d.csrf)csrf=d.csrf;if(d.context?.token)token=d.context.token;return d;
|
||||
};
|
||||
await api('/api/session');await api('/api/login','POST',{email:p.email,password:privateData.password});await api('/api/context','POST',{tenant_id:1,company_id:1,unit_id:1,module_id:'purchasing'});clients[p.role]=api;
|
||||
}
|
||||
const read=who=>clients[who]('/api/sourcing/1');
|
||||
async function command(who,action,data,status=200){const current=await read(who);const payload={operation_key:crypto.randomUUID(),version:current.state.version,...(action==='propose'?{delivery_date:'2027-01-15',delivery_location:'Local de entrega exclusivamente sintético OPER'}:{}),...data};return {result:await clients[who]('/api/sourcing/1/'+action,'POST',payload,status),payload};}
|
||||
const prep='preparador',rev='revisor',dec='decisor',consult='consulta';
|
||||
for(const id of [1,2,3])await command(prep,'select',{supplier_id:id});
|
||||
const quote=(id,price='100.00',days=5)=>({supplier_id:id,reference:'SYNTHETIC-Q-'+id,currency:'MZN',valid_until:'2027-12-31',delivery_days:days,payment_terms:'Pagamento sintético a 30 dias',items:[{line:0,unit_price:price}]});
|
||||
for(const [id,amount] of [[1,'100.00'],[2,'110.00'],[3,'120.00']])await command(prep,'quote',{...quote(id,amount),delivery_days:'5',supplier_id:String(id)});
|
||||
const qSaved=await read(prep);assert.equal(qSaved.request.id,1);assert.equal(qSaved.request.total_minor,10000);assert(!qSaved.comparison.ready);
|
||||
for(const action of ['select','quote','evaluate','propose','confirm'])await clients[consult]('/api/sourcing/1/'+action,'POST',{},403);
|
||||
await clients[consult]('/api/sourcing/1','GET',undefined,409,{'X-SGF-Context':'bad'});await clients[consult]('/api/sourcing/1','GET',undefined,403,{'X-Purchasing-Context':'procurement.third_party'});await clients[consult]('/api/sourcing/9999999','GET',undefined,404);
|
||||
checks.push('Source request reused; three suppliers and quotes; consultation is read-only; context and direct access protected');
|
||||
const evalData=async(id,who)=>{const s=await read(who);return {supplier_id:id,quote_revision:s.state.quotes[id].revision,quality:8,history:8,payment:8,no_conflict:true,reason:'Synthetic independent evaluation for isolated test.'};};
|
||||
// Real concurrent database sessions, not just serial requests to PHP's development server.
|
||||
const initial=await read(rev),workerInput={actor_id:2,payload:{operation_key:crypto.randomUUID(),version:initial.state.version,...await evalData(1,rev)}};fs.writeFileSync(work+'/worker.json',JSON.stringify(workerInput));
|
||||
const worker=(file='worker.json')=>new Promise((resolve,reject)=>{const p=spawn(php,[repo+'/tests/Support/sgf-sourcing-worker.php',work+'/'+file],{cwd:host,env:process.env,windowsHide:true});let out='',err='';p.stdout.on('data',d=>out+=d);p.stderr.on('data',d=>err+=d);p.on('exit',code=>{try{if(code!==0)throw Error(err);resolve(JSON.parse(out));}catch(e){reject(e);}});});
|
||||
const workers=await Promise.all([worker(),worker()]);assert(workers.every(w=>w.status===200),JSON.stringify(workers));assert.deepEqual(workers[0].result,workers[1].result);checks.push('Concurrent identical commands return one committed version');
|
||||
const raceVersion=(await read(rev)).state.version;
|
||||
for(const [file,id] of [['race-a.json',2],['race-b.json',3]])fs.writeFileSync(work+'/'+file,JSON.stringify({actor_id:2,payload:{operation_key:crypto.randomUUID(),version:raceVersion,...await evalData(id,rev)}}));
|
||||
const racing=await Promise.all([worker('race-a.json'),worker('race-b.json')]);assert.deepEqual(racing.map(r=>r.status).sort(),[200,409]);checks.push('Concurrent distinct commands use compare-and-swap; stale version refused');
|
||||
for(const id of [1,2,3]){if(id!==1)await command(rev,'evaluate',await evalData(id,rev));await command(dec,'evaluate',await evalData(id,dec));}
|
||||
let ready=await read(rev);assert(ready.comparison.ready);assert.deepEqual(ready.comparison.top_ids,[1]);
|
||||
const proposed=await command(rev,'propose',{supplier_id:1,comparison_hash:ready.comparison.fingerprint,reason:'Best eligible synthetic quote with exact comparison version.'});
|
||||
await clients[rev]('/api/sourcing/1/propose','POST',proposed.payload);checks.push('Proposal retry is idempotent');
|
||||
const confirmData=async()=>{const d=await read(dec);return {proposal_index:d.state.proposals.length-1,comparison_hash:d.state.proposals.at(-1).comparison_hash,reason:'Independent confirmation of the preserved comparison.'};};
|
||||
// The proposer does not possess confirm; independence is also tested in the shared core.
|
||||
await command(rev,'confirm',await confirmData(),403);
|
||||
await command(prep,'quote',quote(1,'100.00',6));await command(dec,'confirm',await confirmData(),409);checks.push('Changed quote blocks stale confirmation and invalidates evaluations');
|
||||
function control(name){const r=spawnSync(php,[repo+'/tests/Support/sgf-sourcing-controls.php',name],{cwd:host,env:process.env,encoding:'utf8',windowsHide:true});assert.equal(r.status,0,r.stderr);}
|
||||
for(const who of [rev,dec])await command(who,'evaluate',await evalData(1,who));
|
||||
ready=await read(rev);await command(rev,'propose',{supplier_id:1,comparison_hash:ready.comparison.fingerprint,reason:'Re-proposed using current quote revision and comparison.'});
|
||||
control('revoke-evaluate');await command(dec,'confirm',await confirmData(),409);control('restore-evaluate');
|
||||
control('revoke-propose');await clients[rev]('/api/sourcing/1/propose','POST',proposed.payload,403);control('restore-propose');
|
||||
control('revoke-view');await clients[consult]('/api/sourcing/1','GET',undefined,403);control('restore-view');checks.push('Revocation is effective for existing sessions, replay and qualified evaluations');
|
||||
const beforeFail=await read(prep);control('fail-on');await command(prep,'quote',quote(2,'110.00',7),500);control('fail-off');const afterFail=await read(prep);assert.equal(afterFail.state.version,beforeFail.state.version);assert.equal(afterFail.history.length,beforeFail.history.length);checks.push('Injected failure after event rolls back state, event and receipt');
|
||||
const confirmVersion=(await read(dec)).state.version,confirmation=await confirmData();
|
||||
for(const file of ['confirm-a.json','confirm-b.json'])fs.writeFileSync(work+'/'+file,JSON.stringify({actor_id:fixture.user_id,action:'confirm',payload:{operation_key:crypto.randomUUID(),version:confirmVersion,...confirmation}}));
|
||||
const confirmations=await Promise.all([worker('confirm-a.json'),worker('confirm-b.json')]);assert.deepEqual(confirmations.map(r=>r.status).sort(),[200,409]);
|
||||
const confirmed=await read(consult);assert(confirmed.state.confirmed);assert.equal(confirmed.state.confirmed.confirmed_by,fixture.user_id);assert.equal(confirmed.state.confirmed.proposal.proposed_by,2);checks.push('Concurrent confirmations produce one immutable independent award');
|
||||
await command(prep,'quote',quote(1),409);checks.push('Independent confirmation references exact comparison and final state is immutable');
|
||||
const origin=await clients[consult]('/api/internal-requisitions/1');assert.equal(origin.record.version,3);assert.equal(origin.record.total_minor,10000);assert.equal(origin.history.length,3);checks.push('Accepted requisition read path remains intact');
|
||||
await clients[consult]('/purchasing/sourcing/1');await clients[consult]('/purchasing/sourcing/screen.mjs');checks.push('Authenticated read-only screen and module script served through protected routes');
|
||||
console.log(JSON.stringify({passed:true,checks,request_id:1,confirmed_only_in_isolated_copy:true,account_fixture_only_in_copy:true}));
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Models\User;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Http\Request;
|
||||
$host=getenv('SOURCING_CLONE');require $host.'/vendor/autoload.php';$app=require $host.'/bootstrap/app.php';$app->make(Kernel::class)->bootstrap();
|
||||
try{
|
||||
$db=DB::connection();if(!preg_match('/^sgf_sourcing_test_[a-f0-9]{12}$/D',$db->getDatabaseName()))throw new RuntimeException('Copy only');
|
||||
$input=json_decode(file_get_contents($argv[1]),true,32,JSON_THROW_ON_ERROR);$user=User::findOrFail($input['actor_id']);
|
||||
auth()->setUser($user);app(CompanyContext::class)->selectFor($user->id,1,1,1);
|
||||
$request=Request::create('/api/sourcing/1/evaluate','POST');$request->headers->set('X-Purchasing-Context','procurement.internal');$app->instance('request',$request);
|
||||
$result=(new Tempo\Purchasing\SgfSourcing\Workspace($user))->command(1,$input['action']??'evaluate',$input['payload']);
|
||||
echo json_encode(['status'=>200,'result'=>$result]);
|
||||
}catch(Throwable $e){echo json_encode(['status'=>$e instanceof Symfony\Component\HttpKernel\Exception\HttpExceptionInterface?$e->getStatusCode():($e instanceof Tempo\Purchasing\Internal\RequisitionFailure?$e->status:500),'message'=>$e->getMessage()]);}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
function updateFixture(string $original, array $manifest, bool $native = false): string
|
||||
{
|
||||
$root = sys_get_temp_dir().'/sgf-update-'.bin2hex(random_bytes(6));
|
||||
mkdir($root, 0700);
|
||||
$copy = function (string $from, string $to, bool $links = false) use (&$copy) {
|
||||
if (! is_dir($to)) {
|
||||
mkdir($to, 0700, true);
|
||||
}
|
||||
foreach (new DirectoryIterator($from) as $file) {
|
||||
if ($file->isDot() || $file->isLink() || str_starts_with($file->getFilename(), '.')) {
|
||||
continue;
|
||||
}
|
||||
$target = $to.'/'.$file->getFilename();
|
||||
if ($file->isDir()) {
|
||||
$copy($file->getPathname(), $target, $links);
|
||||
} elseif (! $links || ! link($file->getPathname(), $target)) {
|
||||
copy($file->getPathname(), $target);
|
||||
}
|
||||
}
|
||||
};
|
||||
if ($native) {
|
||||
foreach (['app', 'config', 'routes', 'packages', 'resources', 'public'] as $directory) {
|
||||
$copy($original.'/'.$directory, $root.'/'.$directory);
|
||||
}
|
||||
$old = json_decode(file_get_contents(dirname(__DIR__, 2).'/docs/proofs/sgf-composition-delivery.json'), true, 32, JSON_THROW_ON_ERROR)['clone'];
|
||||
$old = realpath($old) ?: '';
|
||||
$temp = str_replace('\\', '/', realpath(sys_get_temp_dir()));
|
||||
$normal = str_replace('\\', '/', $old);
|
||||
$private = str_starts_with(strtolower($normal), strtolower($temp).'/sgf-composition-') && preg_match('/^sgf-composition-[a-f0-9]{12}$/D', basename($old));
|
||||
// Hardlinks only to an earlier disposable dependency copy, never to the actual destination.
|
||||
$copy(($private && is_dir($old.'/vendor')) ? $old.'/vendor' : $original.'/vendor', $root.'/vendor', $private);
|
||||
mkdir($root.'/bootstrap/cache', 0700, true);
|
||||
foreach (['app.php', 'providers.php'] as $file) {
|
||||
copy($original.'/bootstrap/'.$file, $root.'/bootstrap/'.$file);
|
||||
}
|
||||
copy($original.'/composer.json', $root.'/composer.json');
|
||||
copy($original.'/artisan', $root.'/artisan');
|
||||
if (!is_dir($root.'/public')) mkdir($root.'/public', 0700, true);
|
||||
copy($original.'/public/index.php', $root.'/public/index.php');
|
||||
} else {
|
||||
foreach ($manifest['host_overlays'] as $file => $values) {
|
||||
if (! is_dir(dirname($root.'/'.$file))) {
|
||||
mkdir(dirname($root.'/'.$file), 0700, true);
|
||||
}copy($original.'/'.$file, $root.'/'.$file);
|
||||
}
|
||||
}
|
||||
// Frontend overlays are existing host files too; preserve their exact baseline
|
||||
// in native copies without sharing mutable assets with the real destination.
|
||||
foreach ($manifest['host_overlays'] as $file => $values) {
|
||||
if (!is_file($root.'/'.$file)) {
|
||||
if (!is_dir(dirname($root.'/'.$file))) mkdir(dirname($root.'/'.$file),0700,true);
|
||||
copy($original.'/'.$file,$root.'/'.$file);
|
||||
}
|
||||
}
|
||||
foreach (['bootstrap/cache', 'packages/purchasing', 'storage/framework/views', 'storage/framework/cache', 'storage/framework/sessions', 'storage/logs'] as $directory) {
|
||||
if (! is_dir($root.'/'.$directory)) {
|
||||
mkdir($root.'/'.$directory, 0700, true);
|
||||
}
|
||||
}
|
||||
copy($original.'/packages/purchasing/current.json',$root.'/packages/purchasing/current.json');
|
||||
file_put_contents($root.'/storage/framework/down','{"status":503}');
|
||||
|
||||
return $root;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/InboxFixture.php';
|
||||
|
||||
if (getenv('WAREHOUSE_HTTP_FIXTURE') !== '1' || ($_SERVER['REMOTE_ADDR'] ?? null) !== '127.0.0.1') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
if ($path === '/health') {
|
||||
echo 'ready';
|
||||
exit;
|
||||
}
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
if (($_SERVER['HTTP_COOKIE'] ?? '') !== 'SGC_TEST_SESSION=synthetic-authorized') {
|
||||
http_response_code(401);
|
||||
echo '{"message":"synthetic login needed"}';
|
||||
exit;
|
||||
}
|
||||
if (! preg_match('~^/([a-z0-9_-]+)/stock/integracao/entregas$~D', $path, $match) || ($_SERVER['REQUEST_METHOD'] ?? null) !== 'GET') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
$scenario = $match[1];
|
||||
if (preg_match('/^status_([0-9]{3})$/D', $scenario, $code)) {
|
||||
http_response_code((int) $code[1]);
|
||||
echo '{"message":"synthetic-private-body-do-not-log"}';
|
||||
exit;
|
||||
}
|
||||
if ($scenario === 'redirect') {
|
||||
header('Location: /ok/stock/integracao/entregas', true, 302);
|
||||
exit;
|
||||
}
|
||||
if ($scenario === 'html') {
|
||||
header('Content-Type: text/html');
|
||||
echo '<html>synthetic-private-body-do-not-log</html>';
|
||||
exit;
|
||||
}
|
||||
if ($scenario === 'json_error') {
|
||||
echo 'synthetic-private-body-do-not-log';
|
||||
exit;
|
||||
}
|
||||
if ($scenario === 'oversized') {
|
||||
echo str_repeat('x', 16384);
|
||||
exit;
|
||||
}
|
||||
if ($scenario === 'slow') {
|
||||
usleep(1200000);
|
||||
}
|
||||
$cursor = $_GET['cursor'] ?? null;
|
||||
$page = $scenario === 'paged' && $cursor !== null
|
||||
? inboxPage([inboxEvent(2, 'receive', 'sgc:7:warehouse-event:1')], 'cursor-two')
|
||||
: inboxPage([inboxEvent()], 'cursor-one', $scenario === 'paged');
|
||||
if ($scenario === 'wrong_tenant') {
|
||||
$page['items'][0]['tenant_id'] = 8;
|
||||
$page['items'][0] = inboxSign($page['items'][0]);
|
||||
}
|
||||
if ($scenario === 'bad_hash') {
|
||||
$page['items'][0]['lines'][0]['quantity_milli'] = 5000;
|
||||
}
|
||||
$page['fixture_query'] = ['cursor' => $cursor, 'limit' => $_GET['limit'] ?? null];
|
||||
echo json_encode($page, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
$file=realpath($argv[1]??'');$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));if(!$file||!$root||!str_starts_with($file,$root.DIRECTORY_SEPARATOR))throw new RuntimeException('Wrong native return worker input');
|
||||
require getcwd().'/vendor/autoload.php';$app=require getcwd().'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$db=Illuminate\Support\Facades\DB::connection();$i=$db->selectOne("SELECT current_database() AS db,current_user AS role,current_setting('cluster_name') AS cluster,inet_server_port() AS port");
|
||||
if(!preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D',$i->db,$m)||$i->role!=='stock_receipt_qa_'.$m[1]||(int)$i->port!==55439||$i->cluster!=='sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN'))throw new RuntimeException('Wrong worker QA database');
|
||||
$p=json_decode(file_get_contents($file),true,512,JSON_THROW_ON_ERROR);$db->select("SELECT set_config('application_name',?,false)",[$p['application']]);$db->statement("SET lock_timeout='8s'");
|
||||
$actor=App\Models\User::findOrFail($p['actor']);auth()->guard()->setUser($actor);config(['stock_returns.enabled'=>true]);
|
||||
$action=$p['action']??'confirm';if(!in_array($action,['confirm','withdraw','reject'],true))throw new RuntimeException('Invalid worker action');
|
||||
$input=['version'=>1,'reason'=>'Synthetic concurrent native warehouse decision.']+($action==='confirm'?['received_in_warehouse'=>true]:[]);
|
||||
try{if($p['http']??false){$request=Illuminate\Http\Request::create('/api/stock-returns/'.$p['proposal'].'/'.$action,'POST',$input,[],[],['HTTP_ACCEPT'=>'application/json','HTTP_X_REQUESTED_WITH'=>'XMLHttpRequest']);$kernel=$app->make(Illuminate\Contracts\Http\Kernel::class);$response=$kernel->handle($request);$out=['status'=>$response->getStatusCode(),'result'=>json_decode($response->getContent(),true)];$kernel->terminate($request,$response);}else{$flow=app(App\Services\WarehouseReturns::class);$result=$action==='confirm'?$flow->confirm($actor,$p['proposal'],$input):$flow->close($actor,$p['proposal'],$input,$action==='withdraw'?'withdrawn':'rejected');$out=['status'=>200,'result'=>$result];}}catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$out=['status'=>$e->getStatusCode()];}
|
||||
file_put_contents($file.'.result',json_encode($out,JSON_THROW_ON_ERROR));
|
||||
Reference in New Issue
Block a user