Files
compras/tests/Support/sgf-composition-proof.php
2026-11-02 08:08:05 +02:00

148 lines
11 KiB
PHP

<?php
use App\Models\User;
use App\Modules\Purchasing\BudgetAdapter;
use App\Providers\AppServiceProvider;
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
use Tempo\Purchasing\Delivery\Installation;
$original = getenv('IR_SGF_HOST');
$clone = getenv('IR_CLONE');
$root = getenv('IR_CANDIDATE_ROOT');
require $clone.'/vendor/autoload.php';
$app = require $clone.'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
foreach (['0001_01_01_000000_create_users_table.php', '2026_10_25_000000_create_identity_foundation.php', '2026_10_25_010000_add_identity_workflow.php'] as $file) {
(require $clone.'/database/migrations/'.$file)->up();
}
Installation::migrate(DB::connection(), Installation::target(DB::connection()), str_repeat('a', 64), 'Synthetic HTTP composition preparation');
require $clone.'/packages/budget-control/tests/host-support.php';
$f = budgetQaFixture('sgf');
$scope = $f['scope'];
$target = Installation::target(DB::connection());
$scopeKey = implode(':', $scope);
config(['purchasing-operational.enabled' => true, 'purchasing-operational.target' => $target, 'purchasing-operational.scopes' => [$scopeKey]]);
budgetQaCreate('sgf', $f, 1000000, true);
$actor = $f['actor'];
$head = $f['validator'];
DB::table('permission_grants')->insert(['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'user_id' => $actor, 'unit_id' => null, 'permission' => 'access.manage', 'created_at' => now(), 'updated_at' => now()]);
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_HTTP_ENABLED' => '1', 'IR_HTTP_TARGET' => $target, 'IR_HTTP_SCOPE' => $scopeKey], null, 120);
$server->start();
$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');
$check(str_starts_with(str_replace('\\', '/', (new ReflectionClass(BudgetAdapter::class))->getFileName()), str_replace('\\', '/', $clone).'/app/'), 'budget adapter 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']]]];
$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'] 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'];
$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']]]);
$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');
$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');
echo json_encode(['passed' => true, 'checks' => $checks, 'clone' => $clone, 'destination_activated' => false, 'normal_provider_routes' => true, 'browser_used' => false], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
} finally {
$server->stop(1);
}