Files
compras/scripts/internal-requisitions-proof.php
2026-11-02 08:08:05 +02:00

156 lines
9.8 KiB
PHP

<?php
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
require dirname(__DIR__).'/vendor/autoload.php';
$app = require dirname(__DIR__).'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$config = DB::connection('pgsql')->getConfig();
unset($config['name']);
if ($config['host'] !== '127.0.0.1') {
throw new LogicException('Provas apenas no PostgreSQL local isolado.');
}
config(['database.connections.ir_proof_admin' => [...$config, 'url' => null, 'database' => 'postgres']]);
$admin = DB::connection('ir_proof_admin');
$suffix = bin2hex(random_bytes(6));
$database = 'budget_core_sgf_'.$suffix;
$role = 'ir_proof_'.$suffix;
$password = bin2hex(random_bytes(32));
$work = sys_get_temp_dir().'/sgc-ir-proof-'.$suffix;
mkdir($work, 0700);
mkdir($work.'/sessions', 0700);
mkdir($work.'/cache', 0700);
$host = realpath(dirname(__DIR__).'/../sgf-tempo');
$candidate = getenv('IR_CANDIDATE_ROOT') ?: dirname(__DIR__);
$before = json_decode(file_get_contents($host.'/packages/purchasing/current.json'), true, 32, JSON_THROW_ON_ERROR);
$created = false;
$roleCreated = false;
$restore = null;
$restoreCreated = false;
$sourceDatabase = null;
$sourceCreated = false;
$exitCode = 0;
try {
if (! preg_match('/^budget_core_sgf_[a-f0-9]{12}$/D', $database) || ! preg_match('/^ir_proof_[a-f0-9]{12}$/D', $role)) {
throw new LogicException('Identidade descartável inválida.');
}
$admin->statement('CREATE ROLE "'.$role.'" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '.$admin->getPdo()->quote($password));
$roleCreated = true;
$admin->statement('CREATE DATABASE "'.$database.'" OWNER "'.$role.'"');
$created = true;
$env = ['APP_ENV' => 'budget-testing', 'APP_DEBUG' => 'false', 'APP_KEY' => 'base64:'.base64_encode(random_bytes(32)), 'APP_URL' => 'http://127.0.0.1', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_HOST' => $config['host'], 'DB_PORT' => (string) $config['port'], 'DB_DATABASE' => $database, 'DB_USERNAME' => $role, 'DB_PASSWORD' => $password,
'SESSION_DRIVER' => 'file', 'SESSION_FILES' => $work.'/sessions', 'SESSION_COOKIE' => 'sgf_ir_'.$suffix, 'CACHE_STORE' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array', 'BCRYPT_ROUNDS' => '4',
'IR_SGF_HOST' => $host, 'IR_CANDIDATE_ROOT' => $candidate, 'IR_FAULT_FILE' => $work.'/fault.flag', 'IR_WORK_DIR' => $work];
// The project wrapper is the authorised entry to the pinned host PHP.
$process = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $host.'/scripts/sgf.ps1', 'php', $candidate.'/scripts/internal-requisitions-sgf-proof.php'], $host, $env, null, 120);
$process->run();
if (! $process->isSuccessful()) {
echo $process->getOutput();
echo $process->getErrorOutput();
throw new RuntimeException('Prova SGF da candidata falhou.');
}
try {
$proof = json_decode($process->getOutput(), true, 64, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
file_put_contents($work.'/failure.txt', $process->getOutput().$process->getErrorOutput());
if (preg_match('/Candidate proof failed: [^\r\n]+/', $process->getOutput(), $diagnostic)) {
echo $diagnostic[0]."\n";
} else {
echo 'Saída da prova não é JSON; diagnóstico privado em '.$work.'/failure.txt'."\n";
}
throw new RuntimeException('A prova filha não concluiu as verificações.');
}
$after = json_decode(file_get_contents($host.'/packages/purchasing/current.json'), true, 32, JSON_THROW_ON_ERROR);
if ($before !== $after || $after['version'] !== '0.1.1') {
throw new RuntimeException('A instalação activa não devia mudar.');
}
$qa = [...$config, 'url' => null, 'database' => $database, 'username' => $role, 'password' => $password];
config(['database.connections.ir_proof_read' => $qa]);
$db = DB::connection('ir_proof_read');
$tables = array_map(fn ($r) => $r->tablename, $db->select("select tablename from pg_tables where schemaname='public' order by tablename"));
$fingerprint = function ($connection) use ($tables) {
$out = [];
foreach ($tables as $table) {
$quoted = '"'.str_replace('"', '""', $table).'"';
$rows = $connection->select('select to_jsonb(t)::text as value from '.$quoted.' t order by to_jsonb(t)::text');
$out[$table] = hash('sha256', json_encode(array_column($rows, 'value'), JSON_THROW_ON_ERROR));
}
return $out;
};
$baseline = $fingerprint($db);
$dump = $work.'/recovery.dump';
$bin = 'C:/Program Files/PostgreSQL/17/bin/';
$pgEnv = ['PGHOST' => $config['host'], 'PGPORT' => (string) $config['port'], 'PGUSER' => $role, 'PGPASSWORD' => $password];
$backup = new Process([$bin.'pg_dump.exe', '--format=custom', '--file='.$dump, '--dbname='.$database], null, $pgEnv, null, 60);
$backup->mustRun();
$restore = 'budget_core_sgf_'.bin2hex(random_bytes(6));
$admin->statement('CREATE DATABASE "'.$restore.'" OWNER "'.$role.'"');
$restoreCreated = true;
$restoreProcess = new Process([$bin.'pg_restore.exe', '--no-owner', '--no-privileges', '--exit-on-error', '--dbname='.$restore, $dump], null, $pgEnv, null, 60);
$restoreProcess->mustRun();
config(['database.connections.ir_proof_restore' => [...$qa, 'database' => $restore]]);
$restored = DB::connection('ir_proof_restore');
if ($fingerprint($restored) !== $baseline) {
throw new RuntimeException('Dados restaurados diferentes da prova.');
}
$readRestore = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $host.'/scripts/sgf.ps1', 'php', $candidate.'/tests/Support/internal-requisition-sgf-restored.php'], $host, [...$env, 'DB_DATABASE' => $restore], null, 30);
$readRestore->mustRun();
$restoredResult = json_decode($readRestore->getOutput(), true, 32, JSON_THROW_ON_ERROR);
if ($fingerprint($restored) !== $baseline) {
throw new RuntimeException('Consulta alterou os dados restaurados.');
}
$schemaSignature = function ($connection) {
return json_encode([
$connection->select("select table_name,column_name,data_type,character_maximum_length,is_nullable,ordinal_position from information_schema.columns where table_schema='public' order by table_name,ordinal_position"),
$connection->select("select c.relname,t.tgname,pg_get_triggerdef(t.oid) as definition from pg_trigger t join pg_class c on c.oid=t.tgrelid join pg_namespace n on n.oid=c.relnamespace where n.nspname='public' and not t.tgisinternal order by c.relname,t.tgname"),
$connection->select("select c.relname,k.conname,pg_get_constraintdef(k.oid) as definition from pg_constraint k join pg_class c on c.oid=k.conrelid join pg_namespace n on n.oid=c.relnamespace where n.nspname='public' order by c.relname,k.conname"),
], JSON_THROW_ON_ERROR);
};
if ($schemaSignature($db) !== $schemaSignature($restored)) {
file_put_contents($work.'/schema-before.json', $schemaSignature($db));
file_put_contents($work.'/schema-after.json', $schemaSignature($restored));
echo 'Schema diagnostic: '.$work."\n";
throw new RuntimeException('Schema restaurado diferente da prova.');
}
$proof['data_schema_recovery'] = ['backup_sha256' => hash_file('sha256', $dump), 'tables_reconciled' => count($tables), 'restore_verified' => true, 'schema_constraints_and_triggers_verified' => true, 'restored_consultation' => $restoredResult, 'restores_new_operations_automatically' => false, 'separate_from_code_rollback' => true];
$sourceDatabase = 'compras_receipts_test_'.bin2hex(random_bytes(6));
$admin->statement('CREATE DATABASE "'.$sourceDatabase.'" OWNER "'.$role.'"');
$sourceCreated = true;
$sourceEnv = ['APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_HOST' => $config['host'], 'DB_PORT' => (string) $config['port'], 'DB_DATABASE' => $sourceDatabase, 'DB_USERNAME' => $role, 'DB_PASSWORD' => $password];
$sourceTest = new Process([PHP_BINARY, 'vendor/bin/pest', '--compact', 'tests/Feature/InternalRequisitionCandidateTest.php', 'tests/Feature/ProcurementTest.php', 'tests/Feature/BudgetTest.php'], dirname(__DIR__), $sourceEnv, null, 120);
$sourceTest->mustRun();
$proof['source_postgres_tests'] = ['passed' => true, 'output' => trim($sourceTest->getOutput())];
$proof['checked_at'] = gmdate('c');
$proof['isolated_database_removed_in_finally'] = true;
$proof['active_destination_version'] = $after['version'];
$proof['candidate_root'] = $candidate;
file_put_contents(dirname(__DIR__).'/docs/proofs/internal-requisitions-sgf.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
} catch (Throwable $error) {
fwrite(STDERR, $error->getMessage()."\n");
$exitCode = 1;
} finally {
if ($sourceCreated) {
$admin->select('select pg_terminate_backend(pid) from pg_stat_activity where datname=? and pid<>pg_backend_pid()', [$sourceDatabase]);
$admin->statement('DROP DATABASE "'.$sourceDatabase.'"');
}
if ($restoreCreated) {
DB::purge('ir_proof_restore');
$admin->select('select pg_terminate_backend(pid) from pg_stat_activity where datname=? and pid<>pg_backend_pid()', [$restore]);
$admin->statement('DROP DATABASE "'.$restore.'"');
}
DB::purge('ir_proof_read');
if ($created) {
$admin->select('select pg_terminate_backend(pid) from pg_stat_activity where datname=? and pid<>pg_backend_pid()', [$database]);
$admin->statement('DROP DATABASE "'.$database.'"');
}
if ($roleCreated) {
$admin->statement('DROP ROLE "'.$role.'"');
}
// Only the generated workspace is eligible for cleanup; retained on proof failure for diagnosis.
}
exit($exitCode);