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); }