260 lines
18 KiB
PHP
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);
|
|
}
|