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

260 lines
18 KiB
PHP

<?php
// Invoked with SGF's pinned PHP, against one newly created disposable budget database.
use App\Models\User;
use App\Modules\Purchasing\BudgetAdapter;
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\SgfCandidate\Schema;
$host = getenv('IR_SGF_HOST');
$candidate = getenv('IR_CANDIDATE_ROOT');
require $host.'/vendor/autoload.php';
$app = require $host.'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
BudgetAdapter::guard();
require $candidate.'/integrations/sgf/internal-requisitions/bootstrap.php';
$checks = [];
function irCheck(bool $condition, string $name): void
{
global $checks;
if (! $condition) {
throw new RuntimeException('Candidate proof failed: '.$name);
}$checks[] = $name;
}
irCheck(! DB::getSchemaBuilder()->hasTable('users'), 'fresh isolated database');
require $host.'/packages/budget-control/tests/sgf-schema.php';
require $host.'/packages/budget-control/tests/host-support.php';
$f = DB::transaction(fn () => budgetQaFixture('sgf'));
$budget = budgetQaCreate('sgf', $f, 1000000, true);
$oldTables = array_map(fn ($r) => $r->tablename, DB::select("select tablename from pg_tables where schemaname='public' order by tablename"));
$fingerprints = function () use ($oldTables) {
$out = [];
foreach ($oldTables as $name) {
$quoted = '"'.str_replace('"', '""', $name).'"';
$rows = DB::select('select to_jsonb(t)::text as value from '.$quoted.' t order by to_jsonb(t)::text');
$out[$name] = hash('sha256', json_encode(array_column($rows, 'value'), JSON_THROW_ON_ERROR));
}
return $out;
};
$oldFingerprint = $fingerprints();
$failSchema = true;
DB::listen(function ($q) use (&$failSchema) {
if ($failSchema && str_contains($q->sql, 'create table "ir_events"')) {
$failSchema = false;
throw new RuntimeException('Injected schema installation failure');
}
});
try {
Schema::install(DB::connection());
throw new RuntimeException('Schema fault was not injected');
} catch (RuntimeException $e) {
if ($e->getMessage() !== 'Injected schema installation failure') {
throw $e;
}
}
irCheck(! DB::getSchemaBuilder()->hasTable('ir_requests') && $fingerprints() === $oldFingerprint, 'failed additive migration rolls back preserving existing identity grants and budget');
Schema::install(DB::connection());
irCheck($fingerprints() === $oldFingerprint, 'successful additive migration preserves existing data');
$scope = $f['scope'];
$now = now();
$grant = function (int $user, string $permission) use ($scope, $now) {
DB::table('permission_grants')->insertOrIgnore($scope + ['user_id' => $user, 'permission' => $permission, 'created_at' => $now, 'updated_at' => $now]);
};
foreach ([$f['actor'], $f['validator']] as $id) {
foreach (['view', 'prepare', 'approve.department_head'] as $p) {
$grant($id, 'purchasing_requisitions.'.$p);
}$grant($id, 'purchasing_budget.record');
}
$grant($f['actor'], 'purchasing_requisitions.configure');
$passwords = [];
foreach ([$f['actor'], $f['validator']] as $id) {
$u = User::findOrFail($id);
$passwords[$id] = bin2hex(random_bytes(20));
$u->password = $passwords[$id];
$u->save();
}
$socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error);
if (! $socket) {
throw new RuntimeException('No loopback proof port');
}$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', $host.'/public', $candidate.'/tests/Support/internal-requisition-sgf-router.php'], $host, null, null, 120);
$server->start();
try {
for ($i = 0; $i < 80; $i++) {
if (@fsockopen('127.0.0.1', $port, $e, $s, .1)) {
break;
}usleep(50000);
}
$clients = [];
$call = function (int $actor, string $path, string $method = 'GET', ?array $data = null, array $extra = []) use (&$clients, $port) {
$c = &$clients[$actor];
$options = ['http_errors' => false, 'headers' => array_merge(['Accept' => 'application/json', 'X-CSRF-TOKEN' => $c['csrf'], 'X-SGF-Context' => $c['token'] ?? '', 'X-Purchasing-Context' => 'procurement.internal'], $extra)];
if ($data !== null) {
$options['json'] = $data;
}
$r = $c['http']->request($method, 'http://127.0.0.1:'.$port.$path, $options);
return ['status' => $r->getStatusCode(), 'data' => json_decode((string) $r->getBody(), true), 'body' => (string) $r->getBody()];
};
foreach ([$f['actor'], $f['validator']] as $id) {
$http = new Client(['cookies' => new CookieJar, 'timeout' => 10]);
$initial = $http->get('http://127.0.0.1:'.$port.'/api/session', ['headers' => ['Accept' => 'application/json']]);
$session = json_decode((string) $initial->getBody(), true);
$clients[$id] = ['http' => $http, 'csrf' => $session['csrf'], 'token' => ''];
$login = $call($id, '/api/login', 'POST', ['email' => User::findOrFail($id)->email, 'password' => $passwords[$id]]);
irCheck($login['status'] === 200, 'authenticated synthetic identity '.$id);
$clients[$id]['csrf'] = $login['data']['csrf'];
$selection = $call($id, '/api/context', 'POST', $scope);
irCheck($selection['status'] === 200, 'authorised company unit selection '.$id);
$clients[$id]['token'] = $selection['data']['context']['token'];
$clients[$id]['csrf'] = $selection['data']['csrf'];
}
$actor = $f['actor'];
$head = $f['validator'];
$policy = ['version' => 0, 'reason' => 'Política sintética explícita para a candidata.', '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']]];
irCheck($call($actor, '/api/internal-requisitions/policy', 'POST', $policy)['status'] === 200, 'versioned policy without functional grants');
$html = $call($actor, '/purchasing/internal-requisitions');
irCheck($html['status'] === 200 && str_contains($html['body'], 'Guardar rascunho') && str_contains($html['body'], 'Compras internas'), 'authorised workflow screen and context');
$key = (string) Str::uuid();
$input = ['operation_key' => $key, 'cost_center_id' => $f['centre'], 'priority' => 'normal', 'needed_by' => now()->addWeek()->toDateString(), 'justification' => 'Necessidade operacional sintética.',
'items' => [['description' => 'Material', 'category' => 'Consumíveis', 'purchase_type' => 'consumable', 'quantity' => '2.000', 'unit' => 'UN', 'unit_price' => '50.00', 'tax_rate' => '16.00']]];
$draft = $call($actor, '/api/internal-requisitions/save', 'POST', $input);
irCheck($draft['status'] === 200, 'create draft through HTTP');
$r = $draft['data'];
$id = $r['id'];
irCheck($call($actor, '/api/internal-requisitions/save', 'POST', $input, ['X-CSRF-TOKEN' => 'invalid'])['status'] === 419, 'CSRF retained on candidate commands');
$retry = $call($actor, '/api/internal-requisitions/save', 'POST', $input);
irCheck($retry['status'] === 200 && $retry['data']['id'] === $id && $retry['data']['replayed'] && DB::table('ir_requests')->count() === 1, 'create retry one origin');
irCheck($call($actor, '/api/internal-requisitions/save', 'POST', [...$input, 'operation_key' => (string) Str::uuid(), 'tenant_id' => 999])['status'] === 422, 'protected context field refused');
irCheck($call($actor, '/api/internal-requisitions', 'GET', null, ['X-Purchasing-Context' => 'people.internal'])['status'] === 403, 'wrong work area refused');
irCheck($call($actor, '/api/internal-requisitions', 'GET', null, ['X-SGF-Context' => 'stale'])['status'] === 409, 'stale context refused on reads');
irCheck($call($actor, '/api/internal-requisitions/'.$id.'/save', 'POST', [...$input, 'operation_key' => (string) Str::uuid(), 'version' => 99])['status'] === 409, 'stale edit refused');
$submit = ['operation_key' => (string) Str::uuid(), 'version' => $r['version']];
file_put_contents(getenv('IR_FAULT_FILE'), 'before_commit');
irCheck($call($actor, '/api/internal-requisitions/'.$id.'/submit', 'POST', $submit)['status'] === 500, 'failure after reserve injected');
irCheck(DB::table('cb_budget_entries')->where('requisition_id', '!=', $f['origin'])->count() === 0 && DB::table('ir_steps')->count() === 0 && DB::table('ir_requests')->value('status') === 'draft', 'failure rolls back origin reserve steps and request');
file_put_contents(getenv('IR_FAULT_FILE'), 'lost_response');
irCheck($call($actor, '/api/internal-requisitions/'.$id.'/submit', 'POST', $submit)['status'] === 500, 'committed response loss injected');
$submitted = $call($actor, '/api/internal-requisitions/'.$id.'/submit', 'POST', $submit);
irCheck($submitted['status'] === 200 && $submitted['data']['replayed'], 'exact retry recovers committed submission');
irCheck(DB::table('cb_budget_entries')->where('kind', 'reserve')->count() === 1 && (int) DB::table('cb_budget_entries')->sum('reserved_minor') === 11600, 'single existing budget reservation');
$decision = ['operation_key' => (string) Str::uuid(), 'version' => $submitted['data']['version'], 'decision' => 'approved'];
irCheck($call($actor, '/api/internal-requisitions/'.$id.'/decide', 'POST', $decision)['status'] === 403, 'self approval denied with approval grant');
DB::table('permission_grants')->where($scope)->where('user_id', $head)->where('permission', 'purchasing_requisitions.approve.department_head')->delete();
irCheck(in_array($call($head, '/api/internal-requisitions/'.$id.'/decide', 'POST', $decision)['status'], [403, 404], true), 'revoked approval denied at server');
$grant($head, 'purchasing_requisitions.approve.department_head');
$approved = $call($head, '/api/internal-requisitions/'.$id.'/decide', 'POST', $decision);
irCheck($approved['status'] === 200 && $approved['data']['status'] === 'approved', 'independent approval');
$repeat = $call($head, '/api/internal-requisitions/'.$id.'/decide', 'POST', $decision);
irCheck($repeat['status'] === 200 && $repeat['data']['replayed'] && DB::table('ir_events')->count() === 3, 'approval retry preserves one decision and versions');
$view = $call($actor, '/api/internal-requisitions/'.$id);
irCheck($view['status'] === 200 && count($view['data']['history']) === 3 && $view['data']['record']['origin_key'] === $key, 'authorised origin version and history consultation');
DB::table('permission_grants')->where($scope)->where('user_id', $actor)->where('permission', 'purchasing_requisitions.view')->delete();
irCheck($call($actor, '/api/internal-requisitions/'.$id)['status'] === 403, 'revoked read denied');
$grant($actor, 'purchasing_requisitions.view');
$reserved = DB::table('units')->where('company_id', $scope['company_id'])->where('id', '!=', $scope['unit_id'])->value('id');
irCheck($call($actor, '/api/context', 'POST', [...$scope, 'unit_id' => (int) $reserved])['status'] === 403, 'unit selection does not grant access');
$otherUnit = DB::table('units')->insertGetId(['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'name' => 'Outra unidade sintética', 'created_at' => now(), 'updated_at' => now()]);
foreach (['unit.view', 'purchasing_requisitions.view'] as $right) {
DB::table('permission_grants')->insert(['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'unit_id' => $otherUnit, 'user_id' => $actor, 'permission' => $right, 'created_at' => now(), 'updated_at' => now()]);
}
$switch = $call($actor, '/api/context', 'POST', [...$scope, 'unit_id' => $otherUnit]);
irCheck($switch['status'] === 200, 'explicit authorised unit switch');
$clients[$actor]['token'] = $switch['data']['context']['token'];
irCheck($call($actor, '/api/internal-requisitions/'.$id)['status'] === 404 && $call($actor, '/api/internal-requisitions/'.$id.'/submit', 'POST', $submit)['status'] === 404, 'read and operation from previous unit blocked');
$foreignTenant = DB::table('tenants')->insertGetId(['name' => 'Outro cliente sintético', 'created_at' => now(), 'updated_at' => now()]);
$foreignCompany = DB::table('companies')->insertGetId(['tenant_id' => $foreignTenant, 'name' => 'Synthetic purchaser', 'created_at' => now(), 'updated_at' => now()]);
$foreignUnit = DB::table('units')->insertGetId(['tenant_id' => $foreignTenant, 'company_id' => $foreignCompany, 'name' => 'Purchasing QA', 'created_at' => now(), 'updated_at' => now()]);
DB::table('company_memberships')->insert(['tenant_id' => $foreignTenant, 'company_id' => $foreignCompany, 'user_id' => $actor]);
foreach (['company.view' => null, 'unit.view' => $foreignUnit, 'purchasing_requisitions.view' => $foreignUnit] as $right => $unit) {
DB::table('permission_grants')->insert(['tenant_id' => $foreignTenant, 'company_id' => $foreignCompany, 'unit_id' => $unit, 'user_id' => $actor, 'permission' => $right, 'created_at' => now(), 'updated_at' => now()]);
}
$switch = $call($actor, '/api/context', 'POST', ['tenant_id' => $foreignTenant, 'company_id' => $foreignCompany, 'unit_id' => $foreignUnit]);
irCheck($switch['status'] === 200, 'explicit authorised company switch without name matching');
$clients[$actor]['token'] = $switch['data']['context']['token'];
irCheck($call($actor, '/api/internal-requisitions/'.$id)['status'] === 404, 'other company cannot read origin');
$switch = $call($actor, '/api/context', 'POST', $scope);
irCheck($switch['status'] === 200, 'return to original authorised scope');
$clients[$actor]['token'] = $switch['data']['context']['token'];
// Concurrency uses separate PHP processes, not the single-threaded HTTP proof server.
$races = [];
foreach (['save', 'submit', 'decide'] as $action) {
$raceKey = (string) Str::uuid();
if ($action === 'save') {
$raceId = null;
$raceVersion = null;
$body = $input;
unset($body['operation_key']);
} else {
$last = DB::table('ir_requests')->orderByDesc('id')->first();
$raceId = (int) $last->id;
$raceVersion = (int) $last->version;
$body = $action === 'decide' ? ['decision' => 'approved', 'comment' => null] : [];
}
$who = $action === 'decide' ? $head : $actor;
$worker = ['actor' => $who, 'scope' => $scope, 'key' => $raceKey, 'action' => $action, 'id' => $raceId, 'version' => $raceVersion, 'data' => $body];
$fileA = getenv('IR_WORK_DIR').'/race-a.json';
$fileB = getenv('IR_WORK_DIR').'/race-b.json';
file_put_contents($fileA, json_encode($worker + ['pause' => true], JSON_THROW_ON_ERROR));
file_put_contents($fileB, json_encode($worker, JSON_THROW_ON_ERROR));
$a = new Process([PHP_BINARY, $candidate.'/tests/Support/internal-requisition-sgf-worker.php', $fileA], $host, null, null, 20);
$a->start();
for ($wait = 0; $wait < 300 && ! str_contains($a->getOutput(), 'LOCK_HELD') && $a->isRunning(); $wait++) {
usleep(20000);
}
if (! str_contains($a->getOutput(), 'LOCK_HELD')) {
file_put_contents(getenv('IR_WORK_DIR').'/worker-failure.txt', $a->getOutput().$a->getErrorOutput());
}
irCheck(str_contains($a->getOutput(), 'LOCK_HELD'), 'first transaction owns scope lock '.$action);
$b = new Process([PHP_BINARY, $candidate.'/tests/Support/internal-requisition-sgf-worker.php', $fileB], $host, null, null, 20);
$b->start();
usleep(200000);
$blocked = $b->isRunning();
$a->wait();
$b->wait();
$ra = json_decode(trim(str_replace("LOCK_HELD\n", '', $a->getOutput())), true);
$rb = json_decode(trim($b->getOutput()), true);
irCheck($blocked && $ra['result'] === 'ok' && $rb['result'] === 'ok' && $ra['id'] === $rb['id'] && $rb['replayed'], 'concurrent retry serialized without duplicate '.$action);
$races[] = $action;
}
irCheck(DB::table('ir_requests')->count() === 2 && DB::table('cb_budget_entries')->where('kind', 'reserve')->count() === 2, 'concurrent whole lifecycle produced one request reserve per origin');
$before = DB::table('ir_events')->count();
try {
DB::table('ir_events')->where('request_id', $id)->update(['action' => 'tamper']);
throw new RuntimeException('history accepted tamper');
} catch (QueryException $e) {
}
irCheck(DB::table('ir_events')->count() === $before, 'history SQL immutability');
// Additive compatibility experiment is separate from package-code rollback.
$baseline = DB::table('ir_requests')->orderBy('id')->get()->map(fn ($r) => (array) $r)->all();
try {
DB::transaction(function () {
DB::statement('ALTER TABLE ir_requests ADD COLUMN experimental_nullable text');
throw new RuntimeException('Injected migration failure');
});
} catch (RuntimeException $e) {
}
irCheck(! DB::getSchemaBuilder()->hasColumn('ir_requests', 'experimental_nullable'), 'failed schema transaction recovered');
DB::statement('ALTER TABLE ir_requests ADD COLUMN compatible_nullable text');
$after = DB::table('ir_requests')->orderBy('id')->get()->map(function ($r) {
$v = (array) $r;
unset($v['compatible_nullable']);
return $v;
})->all();
irCheck($baseline === $after && $call($actor, '/api/internal-requisitions/'.$id)['status'] === 200, 'old candidate reads additive compatible schema without rewriting data');
echo json_encode(['passed' => true, 'checks' => $checks, 'concurrency' => $races, 'scope' => $scope, 'requests' => 2, 'budget_reservations' => 2, 'schema_failure_recovered' => true, 'additive_schema_compatible' => true, 'active_destination_changed' => false], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
} finally {
$server->stop(2);
}