This commit is contained in:
2026-11-02 08:08:05 +02:00
parent 13a6cb9922
commit 504b01b244
246 changed files with 24499 additions and 185 deletions
+29
View File
@@ -0,0 +1,29 @@
<?php
use App\Services\ReceiptWorkflow;
use App\Services\RequisitionCatalogue;
use App\Services\SupplyPlanning;
require_once __DIR__.'/ReceiptFixture.php';
require_once __DIR__.'/SupplyFixture.php';
function suggestionFixture(string $currency = 'MZN'): array
{
$f = receiptFixture('120.000');
supplyGrant($f['issuer'], ['procurement', 'requester', 'supply_prepare', 'supply_suggest_prepare', 'supply_suggest_convert']);
supplyGrant($f['receiver'], ['warehouse', 'supply_review', 'supply_suggest_review', 'supply_reserve']);
auth()->guard()->setUser($f['receiver']);
app(ReceiptWorkflow::class)->confirm($f['receiver'], $f['order'], receiptPayload($f, '120.000'));
app(RequisitionCatalogue::class)->save($f['receiver'], $f['stock']->id, ['version' => 0, 'stock_basis' => app(RequisitionCatalogue::class)->basis($f['stock']), 'category' => 'Material', 'purchase_type' => 'consumable', 'published' => true, 'reason' => 'Publicação fictícia para converter sem redigitar.']);
auth()->guard()->setUser($f['issuer']);
$agreement = app(SupplyPlanning::class)->save($f['issuer'], 'agreement', supplyInput(['stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'supplier_id' => $f['order']->supplier_id,
'habitual' => true, 'valid_from' => today()->toDateString(), 'valid_until' => today()->addYear()->toDateString(), 'origin' => $currency === 'MZN' ? 'local' : 'import', 'currency' => $currency, 'price_minor' => 1000, 'lead_days' => 4, 'reference' => 'Acordo fictício do ensaio.']));
auth()->guard()->setUser($f['receiver']);
$f['agreement'] = app(SupplyPlanning::class)->save($f['receiver'], 'agreement', supplyInput([], 'approve', $agreement));
auth()->guard()->setUser($f['issuer']);
$policy = app(SupplyPlanning::class)->save($f['issuer'], 'policy', supplyInput([...supplyPolicy($f), 'agreement_key' => $agreement->record_key]));
auth()->guard()->setUser($f['receiver']);
$f['policy'] = app(SupplyPlanning::class)->save($f['receiver'], 'policy', supplyInput([], 'approve', $policy));
return $f;
}
+24
View File
@@ -0,0 +1,24 @@
<?php
use App\Models\SupplyRecord;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
function supplyGrant($user, array $permissions): void
{
DB::table('organization_access')->insertOrIgnore(['tenant_id' => $user->tenant_id, 'active' => true, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
$profile = DB::table('organization_profiles')->insertGetId(['tenant_id' => $user->tenant_id, 'name' => 'Teste '.Str::uuid(), 'permissions' => json_encode($permissions), 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
DB::table('organization_memberships')->insert(['tenant_id' => $user->tenant_id, 'user_id' => $user->id, 'profile_id' => $profile, 'maintenance' => false, 'created_at' => now(), 'updated_at' => now()]);
}
function supplyPolicy(array $f): array
{
return ['stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'purpose' => 'resale', 'origin' => 'import',
'safety_milli' => 20000, 'minimum_stock_milli' => 20000, 'target_milli' => 100000, 'maximum_milli' => 120000,
'daily_milli' => 10000, 'lead_days' => 4, 'approval_days' => 1, 'horizon_days' => 30, 'minimum_milli' => 24000, 'pack_milli' => 12000, 'basis' => 'Consumo e prazo contratual verificados no ensaio.'];
}
function supplyInput(array $data, string $action = 'save', ?SupplyRecord $record = null): array
{
return ['operation_key' => (string) Str::uuid(), 'record_key' => $record?->record_key, 'version' => $record?->version ?? 0, 'action' => $action, 'reason' => 'Conferência fictícia com evidência no ensaio.', 'data' => $data];
}
@@ -0,0 +1,23 @@
<?php
use App\Models\User;
use App\Shared\Context\CompanyContext;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use Tempo\Purchasing\Internal\InternalRequisitions;
use Tempo\Purchasing\SgfCandidate\Port;
require getenv('IR_SGF_HOST').'/vendor/autoload.php';
$app = require getenv('IR_SGF_HOST').'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
require getenv('IR_CANDIDATE_ROOT').'/integrations/sgf/internal-requisitions/bootstrap.php';
$row = DB::table('ir_requests')->orderBy('id')->first();
$actor = User::findOrFail($row->requester_id);
auth()->guard()->setUser($actor);
app(CompanyContext::class)->selectFor($actor->id, $row->tenant_id, $row->company_id, $row->unit_id);
request()->headers->set('X-Purchasing-Context', 'procurement.internal');
$record = (new InternalRequisitions(new Port($actor)))->view($row->id);
if ($record['status'] !== 'approved' || $record['origin_key'] !== $row->origin_key) {
throw new RuntimeException('Restored process cannot be consulted.');
}
echo json_encode(['restored_candidate_consultation' => true, 'id' => $record['id'], 'origin_preserved' => true, 'version' => $record['version']], JSON_THROW_ON_ERROR)."\n";
@@ -0,0 +1,35 @@
<?php
use App\Modules\Purchasing\BudgetAdapter;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
// Test harness only. Uses the SGF HTTP stack, sessions, CSRF and context middleware.
$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();
config(['logging.default' => 'single', 'logging.channels.single.path' => getenv('IR_WORK_DIR').'/candidate-errors.log']);
BudgetAdapter::guard();
require $candidate.'/integrations/sgf/internal-requisitions/bootstrap.php';
require $candidate.'/integrations/sgf/internal-requisitions/routes.php';
$fault = getenv('IR_FAULT_FILE');
if (is_file($fault) && trim(file_get_contents($fault)) === 'before_commit') {
DB::listen(function ($query) use ($fault) {
if (str_contains($query->sql, 'insert into "ir_events"') && in_array('submitted', $query->bindings, true)) {
unlink($fault);
throw new RuntimeException('Injected failure before commit');
}
});
}
$request = Request::capture();
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$response = $kernel->handle($request);
if (is_file($fault) && trim(file_get_contents($fault)) === 'lost_response' && $request->isMethod('POST') && $request->is('api/internal-requisitions/*')) {
unlink($fault);
$response = response()->json(['message' => 'Injected lost response after commit'], 500);
}
$response->send();
$kernel->terminate($request, $response);
@@ -0,0 +1,34 @@
<?php
use App\Models\User;
use App\Shared\Context\CompanyContext;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use Tempo\Purchasing\Internal\InternalRequisitions;
use Tempo\Purchasing\SgfCandidate\Port;
require getenv('IR_SGF_HOST').'/vendor/autoload.php';
$app = require getenv('IR_SGF_HOST').'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
require getenv('IR_CANDIDATE_ROOT').'/integrations/sgf/internal-requisitions/bootstrap.php';
$input = json_decode(file_get_contents($argv[1]), true, 64, JSON_THROW_ON_ERROR);
$actor = User::findOrFail($input['actor']);
auth()->guard()->setUser($actor);
app(CompanyContext::class)->selectFor($actor->id, $input['scope']['tenant_id'], $input['scope']['company_id'], $input['scope']['unit_id']);
request()->headers->set('X-Purchasing-Context', 'procurement.internal');
try {
$result = DB::transaction(function () use ($input, $actor) {
if (($input['pause'] ?? false)) {
DB::table('companies')->where('id', $input['scope']['company_id'])->lockForUpdate()->firstOrFail();
echo "LOCK_HELD\n";
flush();
usleep(1200000);
}
return (new InternalRequisitions(new Port($actor, $input['key'])))->command($input['action'], $input['id'], $input['version'], $input['key'], $input['data']);
});
echo json_encode(['result' => 'ok', 'id' => $result['id'], 'version' => $result['version'], 'replayed' => $result['replayed'] ?? false], JSON_THROW_ON_ERROR)."\n";
} catch (Throwable $e) {
echo json_encode(['result' => 'rejected', 'class' => $e::class], JSON_THROW_ON_ERROR)."\n";
}
+141
View File
@@ -0,0 +1,141 @@
<?php
use App\Models\User;
use App\Modules\Identity\Services\IdentityWorkflow;
use App\Modules\Identity\Services\Permissions;
use App\Modules\Purchasing\BudgetAdapter;
use App\Shared\Context\CompanyContext;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Http\Request;
use Illuminate\Session\ArraySessionHandler;
use Illuminate\Session\Store;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tempo\Purchasing\Delivery\InitialAuthority;
use Tempo\Purchasing\SgfCandidate\Schema;
$host = getenv('IR_SGF_HOST');
$root = getenv('IR_CANDIDATE_ROOT');
$overlay = getenv('IR_ACCESS_OVERLAY');
require $host.'/vendor/autoload.php';
// Class replacements exist only in this isolated process, not in the installed host.
require $overlay.'/app/Modules/Identity/Services/Permissions.php';
require $overlay.'/app/Modules/Identity/Services/IdentityWorkflow.php';
require $overlay.'/AccessOverlay.php';
require $overlay.'/InitialAuthority.php';
$app = require $host.'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
BudgetAdapter::guard();
require $host.'/packages/budget-control/tests/sgf-schema.php';
require $host.'/packages/budget-control/tests/host-support.php';
$f = budgetQaFixture('sgf');
require $root.'/integrations/sgf/internal-requisitions/Schema.php';
Schema::install(DB::connection());
$scope = $f['scope'];
$actor = $f['actor'];
$target = $f['validator'];
$checks = [];
$check = function (bool $ok, string $name) use (&$checks) {
if (! $ok) {
throw new RuntimeException($name);
}
$checks[] = $name;
};
$deny = function (callable $work, int $status) use ($check) {
try {
$work();
throw new RuntimeException('Unexpected permission success');
} catch (HttpException $e) {
$check($e->getStatusCode() === $status, 'denied '.$status);
}
};
$grant = function ($user, $right, $unit = null) use ($scope) {
DB::table('permission_grants')->insertOrIgnore(['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'unit_id' => $unit, 'user_id' => $user, 'permission' => $right, 'created_at' => now(), 'updated_at' => now()]);
};
$ctx = app(CompanyContext::class);
$ctx->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
$request = function (array $data) use ($actor) {
$r = Request::create('/api/access', 'POST', $data);
$r->setUserResolver(fn () => User::findOrFail($actor));
$session = new Store('access-proof', new ArraySessionHandler(120));
$session->start();
$session->put('company_context.token', 'isolated-context-token');
$r->setLaravelSession($session);
$r->headers->set('X-SGF-Context', 'isolated-context-token');
return $r;
};
$p = app(Permissions::class);
$workflow = app(IdentityWorkflow::class);
$grant($actor, 'access.manage');
$grant($actor, 'unit.create');
$grant($target, 'access.manage');
$check(in_array('purchasing_requisitions.configure', $workflow->accessList($request([]))['catalogue']['unit'], true), 'existing administration lists new catalogue');
$unit = $workflow->createUnit($request(['name' => 'Synthetic new unit']));
$check(DB::table('permission_grants')->where('unit_id', $unit)->count() === 2, 'unit creation grants only legacy rights');
$initial = ['operation_key' => (string) Str::uuid(), 'reason' => 'Entrega inicial sintética explicitamente escolhida.', 'administrators' => [['user_id' => $actor, 'rights' => ['purchasing_requisitions.view', 'purchasing_requisitions.configure']]]];
$service = new InitialAuthority;
$deny(fn () => $service->initialise($request($initial)), 403);
config(['purchasing-access.initialisation_scopes' => [implode(':', $scope)]]);
$stale = $request($initial);
$stale->headers->set('X-SGF-Context', 'old-token');
$deny(fn () => $service->initialise($stale), 409);
$options = $service->options($request([]));
$check(count($options['administrators']) === 2 && ! $options['initialised'], 'nominative authorised initialisation choices');
$render = function (array $options, Request $request) use ($overlay) {
ob_start();
require $overlay.'/screen.php';
return ob_get_clean();
};
$html = $render($options, $request([]));
$check(str_contains($html, 'name="rights"') && str_contains($html, 'X-SGF-Context') && str_contains($html, 'X-CSRF-TOKEN'), 'initial authority screen selects explicit rights and includes context and CSRF');
$check(! str_contains($html, ' checked') && str_contains($html, 'crypto.randomUUID()'), 'screen has no selected grants and retains operation key on retry');
$deny(fn () => $service->initialise($request([...$initial, 'administrators' => [['user_id' => $actor, 'rights' => ['company.edit']]]])), 422);
$failAudit = true;
DB::listen(function ($query) use (&$failAudit) {
if ($failAudit && str_contains($query->sql, 'insert into "identity_audit"') && in_array('purchasing.access_initialised', $query->bindings, true)) {
$failAudit = false;
throw new RuntimeException('Injected authority audit failure');
}
});
try {
$service->initialise($request($initial));
throw new RuntimeException('Expected audit failure');
} catch (RuntimeException $error) {
if ($error->getMessage() !== 'Injected authority audit failure') {
throw $error;
}
}
$check(! $p->has($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_requisitions.configure', $scope['unit_id']), 'audit failure rolls back initial grants');
$before = DB::table('identity_audit')->count();
$check($service->initialise($request($initial))['initialised'], 'explicit nominated administrator initialisation');
$check(DB::table('identity_audit')->count() === $before + 1, 'initial authority audited');
$check(! $p->has($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_requisitions.approve.finance', $scope['unit_id']), 'unselected rights absent');
$check($service->initialise($request($initial))['replayed'], 'initialisation retry idempotent');
$options = $service->options($request([]));
$check($options['initialised'] && ! str_contains($render($options, $request([])), '<form'), 'initialised screen cannot offer a second initial grant');
$change = ['email' => User::findOrFail($target)->email, 'permission' => 'purchasing_requisitions.configure', 'unit_id' => $scope['unit_id'], 'reason' => 'Delegação sintética explícita.'];
$workflow->changeAccess($request($change), true);
$check($p->has($target, $scope['tenant_id'], $scope['company_id'], $change['permission'], $scope['unit_id']), 'normal administrative grant');
$workflow->changeAccess($request($change), false);
$check(! $p->has($target, $scope['tenant_id'], $scope['company_id'], $change['permission'], $scope['unit_id']), 'normal administrative revocation');
$deny(fn () => $workflow->changeAccess($request([...$change, 'permission' => 'purchasing_requisitions.approve.finance']), true), 403);
$grant($target, 'purchasing_requisitions.approve.finance', $scope['unit_id']);
$deny(fn () => $workflow->changeAccess($request([...$change, 'permission' => 'unit.view']), false), 403);
$check($p->has($target, $scope['tenant_id'], $scope['company_id'], 'unit.view', $scope['unit_id']), 'failed cascade preserves unit access');
$grant($actor, 'purchasing_requisitions.approve.finance', $scope['unit_id']);
// Fixture also assigned budget rights; actor needs each affected right to revoke the full unit.
foreach (DB::table('permission_grants')->where($scope)->where('user_id', $target)->pluck('permission') as $right) {
$grant($actor, $right, $scope['unit_id']);
}
$workflow->changeAccess($request([...$change, 'permission' => 'unit.view']), false);
$check(DB::table('permission_grants')->where($scope)->where('user_id', $target)->count() === 0, 'unit revocation removes derived catalogue rights');
DB::table('permission_grants')->where($scope)->where('user_id', $actor)->where('permission', 'purchasing_requisitions.configure')->delete();
$check($service->initialise($request($initial))['replayed'] && ! $p->has($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_requisitions.configure', $scope['unit_id']), 'initialisation replay never resurrects revoked rights');
$deny(fn () => $service->initialise($request([...$initial, 'operation_key' => (string) Str::uuid()])), 409);
DB::table('permission_grants')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('user_id', $actor)->where('permission', 'access.manage')->delete();
$deny(fn () => $service->initialise($request($initial)), 403);
$deny(fn () => $service->options($request([])), 403);
echo json_encode(['passed' => true, 'checks' => $checks, 'installed' => false], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
+50
View File
@@ -0,0 +1,50 @@
<?php
/** Code/dependencies only: no .env, cache, accounts, documents or runtime data copied. */
function compositionClone(string $host, string $bundle, string $suffix): string
{
$clone = sys_get_temp_dir().'/sgf-composition-'.$suffix;
if (file_exists($clone)) {
throw new RuntimeException('Clone must be new.');
}
mkdir($clone, 0700);
copy($host.'/composer.json', $clone.'/composer.json');
$copy = function (string $source, string $target) use (&$copy): void {
if (! is_dir($target)) {
mkdir($target, 0700, true);
}
foreach (new DirectoryIterator($source) as $file) {
if ($file->isDot() || $file->isLink() || str_starts_with($file->getFilename(), '.')) {
continue;
}
$to = $target.'/'.$file->getFilename();
if ($file->isDir()) {
$copy($file->getPathname(), $to);
} else {
copy($file->getPathname(), $to);
}
}
};
foreach (['app', 'config', 'routes', 'database/migrations', 'packages', 'vendor'] as $directory) {
$copy($host.'/'.$directory, $clone.'/'.$directory);
}
mkdir($clone.'/bootstrap/cache', 0700, true);
foreach (['app.php', 'providers.php'] as $file) {
copy($host.'/bootstrap/'.$file, $clone.'/bootstrap/'.$file);
}
foreach (['public', 'storage/logs', 'storage/framework/sessions', 'storage/framework/views', 'storage/framework/cache'] as $directory) {
mkdir($clone.'/'.$directory, 0700, true);
}
$copy($bundle, $clone.'/packages/internal-requisitions');
$manifest = json_decode(file_get_contents($bundle.'/manifest.json'), true, 32, JSON_THROW_ON_ERROR);
foreach ($manifest['host_overlays'] as $file => $hashes) {
if (hash_file('sha256', $clone.'/'.$file) !== $hashes['before_sha256']) {
throw new RuntimeException('Clone baseline mismatch.');
}
copy($bundle.'/'.$file, $clone.'/'.$file);
}
file_put_contents($clone.'/config/purchasing-operational.php', '<?php return ["enabled"=>getenv("IR_HTTP_ENABLED")==="1","target"=>getenv("IR_HTTP_TARGET"),"scopes"=>[getenv("IR_HTTP_SCOPE")]];');
file_put_contents($clone.'/config/purchasing-access.php', '<?php return ["initialisation_scopes"=>[getenv("IR_HTTP_SCOPE")]];');
return $clone;
}
+147
View File
@@ -0,0 +1,147 @@
<?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);
}
+9
View File
@@ -0,0 +1,9 @@
<?php
use Illuminate\Http\Request;
// Normal dependency/bootstrap stack of the disposable copy; provider registers module routes.
$clone = getenv('IR_CLONE');
require $clone.'/vendor/autoload.php';
$app = require $clone.'/bootstrap/app.php';
$app->handleRequest(Request::capture());
+179
View File
@@ -0,0 +1,179 @@
<?php
use App\Models\User;
use App\Modules\Identity\Services\Permissions;
use App\Modules\Purchasing\BudgetAdapter;
use App\Shared\Context\CompanyContext;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Process\Process;
use Tempo\Purchasing\Delivery\Installation;
$host = getenv('IR_SGF_HOST');
$root = getenv('IR_ACCESS_OVERLAY');
require $host.'/vendor/autoload.php';
foreach (['Identity/Services/Permissions', 'Identity/Services/IdentityWorkflow', 'Purchasing/BudgetAdapter'] as $file) {
require $root.'/app/Modules/'.$file.'.php';
}
$app = require $host.'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
require $root.'/integrations/sgf/operational/bootstrap.php';
if (getenv('IR_RESTORE_VERIFY') === '1') {
$db = DB::connection();
$budget = DB::table('cb_budgets')->first();
$scope = ['tenant_id' => $budget->tenant_id, 'company_id' => $budget->company_id, 'unit_id' => $budget->unit_id];
$actor = $budget->created_by;
auth()->loginUsingId($actor);
app(CompanyContext::class)->selectFor($actor, ...array_values($scope));
config(['purchasing-operational.enabled' => true, 'purchasing-operational.target' => Installation::target($db), 'purchasing-operational.scopes' => [implode(':', $scope)]]);
BudgetAdapter::guard();
$receipt = $db->table('sgc_installations')->first();
if ($db->table('cb_budget_entries')->where('kind', 'reserve')->count() !== 1 || ! hash_equals($receipt->schema_signature, Installation::signature($db, Installation::tables()))) {
throw new RuntimeException('Operational recovery differs.');
}
if (app(Permissions::class)->has($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_budget.record', $scope['unit_id'])) {
throw new RuntimeException('Restored revocation was lost.');
}
echo json_encode(['verified' => true, 'production_environment' => app()->environment('production'), 'database_outside_QA_prefix' => ! str_starts_with($db->getDatabaseName(), 'budget_core_sgf_'), 'schema_receipt_and_budget_reservation_preserved' => true, 'revocation_preserved' => true], JSON_THROW_ON_ERROR);
exit(0);
}
BudgetAdapter::guard();
require $host.'/packages/budget-control/tests/sgf-schema.php';
if (getenv('IR_CALIBRATE') === '1') {
echo json_encode(['passed' => true, 'schema_signature' => Installation::signature(DB::connection(), Installation::TABLES)], JSON_THROW_ON_ERROR);
exit(0);
}
require $host.'/packages/budget-control/tests/host-support.php';
$f = budgetQaFixture('sgf');
budgetQaCreate('sgf', $f, 1000000, true);
$db = DB::connection();
$scope = $f['scope'];
$ctx = app(CompanyContext::class);
auth()->loginUsingId($f['actor']);
$ctx->selectFor($f['actor'], $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
$target = Installation::target($db);
$beforeGrants = DB::table('permission_grants')->count();
$beforeBudget = DB::table('cb_budgets')->first();
$checks = [];
$check = function ($ok, $name) use (&$checks) {
if (! $ok) {
throw new RuntimeException($name);
}
$checks[] = $name;
};
$deny = function (callable $call, string $message) use ($check) {
try {
$call();
throw new LogicException('Unexpected success');
} catch (RuntimeException $error) {
$check($error->getMessage() === $message, $message);
}
};
$cli = function (array $args) use ($host, $root) {
$process = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $host.'/scripts/sgf.ps1', 'php', $root.'/scripts/sgf-internal-install.php', ...$args], $host, ['PG_RESTORE_BINARY' => 'C:/Program Files/PostgreSQL/17/bin/pg_restore.exe'], null, 60);
$process->run();
return $process;
};
$plan = $cli(['plan', $host]);
$plan->mustRun();
$planResult = json_decode($plan->getOutput(), true, 16, JSON_THROW_ON_ERROR);
$check($planResult['target_sha256'] === $target && ! $planResult['schema_prepared'] && ! $db->getSchemaBuilder()->hasTable('sgc_installations'), 'CLI plan is read-only and identifies destination');
$backup = sys_get_temp_dir().'/sgf-install-backup-'.bin2hex(random_bytes(6)).'.dump';
$connection = $db->getConfig();
$dump = new Process(['C:/Program Files/PostgreSQL/17/bin/pg_dump.exe', '--format=custom', '--file='.$backup, '--dbname='.$db->getDatabaseName()], null,
['PGHOST' => $connection['host'], 'PGPORT' => (string) $connection['port'], 'PGUSER' => $connection['username'], 'PGPASSWORD' => $connection['password']], null, 60);
$dump->mustRun();
$backupHash = hash_file('sha256', $backup);
$invalidBackup = $cli(['migrate', $host, $target, $backup, str_repeat('0', 64), 'Synthetic operator', $planResult['manifest_sha256']]);
$check(! $invalidBackup->isSuccessful() && str_contains($invalidBackup->getErrorOutput(), 'Backup ou manifesto'), 'CLI refuses unverified backup before schema write');
$deny(fn () => Installation::migrate($db, str_repeat('0', 64), str_repeat('a', 64), 'Synthetic operator'), 'Destino, backup ou operador não conferidos.');
$deny(fn () => DB::transaction(function () use ($db, $target) {
$db->getSchemaBuilder()->drop('cb_audit');
Installation::migrate($db, $target, str_repeat('a', 64), 'Synthetic operator');
}), 'Schema orçamental parcial; não completar às cegas.');
$deny(fn () => DB::transaction(function () use ($db, $target) {
$db->statement('ALTER TABLE cb_budgets ADD COLUMN unexpected_field text');
Installation::migrate($db, $target, str_repeat('a', 64), 'Synthetic operator');
}), 'Schema orçamental incompatível com o contrato aceite.');
try {
DB::transaction(function () use ($db, $target, $check, $scope, $f) {
foreach (array_reverse(Installation::TABLES) as $table) {
$db->getSchemaBuilder()->drop($table);
}
DB::table('permission_grants')->where('permission', 'like', 'purchasing_budget.%')->delete();
$db->statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
$db->statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((permission IN ('company.view','company.edit','unit.create','access.manage') AND unit_id IS NULL) OR (permission IN ('unit.view','unit.edit') AND unit_id IS NOT NULL))");
$result = Installation::migrate($db, $target, str_repeat('a', 64), 'Synthetic operator');
$check($result['prepared'] && DB::table('cb_budgets')->count() === 0, 'clean budget schema installed without synthetic records');
DB::table('permission_grants')->insert($scope + ['user_id' => $f['actor'], 'permission' => 'purchasing_budget.view', 'created_at' => now(), 'updated_at' => now()]);
$check(true, 'clean identity catalogue supports budget rights without automatic grants');
throw new RuntimeException('Restore isolated existing fixture');
});
} catch (RuntimeException $error) {
if ($error->getMessage() !== 'Restore isolated existing fixture') {
throw $error;
}
}
$fail = true;
DB::listen(function ($q) use (&$fail) {
if ($fail && str_contains($q->sql, 'create table "ir_events"')) {
$fail = false;
throw new RuntimeException('Injected operational DDL failure');
}
});
$deny(fn () => Installation::migrate($db, $target, str_repeat('a', 64), 'Synthetic operator'), 'Injected operational DDL failure');
$check(! $db->getSchemaBuilder()->hasTable('ir_requests') && ! $db->getSchemaBuilder()->hasTable('sgc_installations'), 'failed installation leaves no partial module or receipt');
$check((array) DB::table('cb_budgets')->first() === (array) $beforeBudget && DB::table('permission_grants')->count() === $beforeGrants, 'failed preparation preserves existing budget and rights');
$migrate = $cli(['migrate', $host, $target, $backup, $backupHash, 'Synthetic deployment operator', $planResult['manifest_sha256']]);
if (! $migrate->isSuccessful()) {
throw new RuntimeException($migrate->getErrorOutput());
}
$result = json_decode($migrate->getOutput(), true, 16, JSON_THROW_ON_ERROR);
$check($result['prepared'] && ! $result['activated'], 'schema prepared without code activation');
$check(DB::table('sgc_installations')->value('backup_sha256') === $backupHash, 'CLI records actual verified custom backup hash');
unlink($backup);
$check(Installation::migrate($db, $target, str_repeat('a', 64), 'Synthetic deployment operator')['replayed'] && DB::table('sgc_installations')->count() === 1, 'installation retry preserves one receipt');
$check(DB::table('permission_grants')->count() === $beforeGrants && (array) DB::table('cb_budgets')->first() === (array) $beforeBudget, 'successful preparation preserves grants and approved budget');
$app->detectEnvironment(fn () => 'production');
config(['purchasing-operational.enabled' => true, 'purchasing-operational.target' => $target, 'purchasing-operational.scopes' => [implode(':', $scope)]]);
BudgetAdapter::guard();
$check(true, 'operational composition works outside QA environment');
$reservation = app(BudgetAdapter::class)->record(User::findOrFail($f['actor']), $f['origin'], 'reserve', 11600, 10000);
$check($reservation !== null && DB::table('cb_budget_entries')->where('kind', 'reserve')->count() === 1, 'operational composition reuses existing budget reservation motor');
DB::table('permission_grants')->where($scope)->where('user_id', $f['actor'])->where('permission', 'purchasing_budget.record')->delete();
try {
app(BudgetAdapter::class)->record(User::findOrFail($f['actor']), $f['origin'], 'release');
throw new LogicException('Revoked right accepted');
} catch (HttpException $error) {
$check($error->getStatusCode() === 403, 'operational budget checks revoked permission');
}
config(['purchasing-operational.scopes' => []]);
try {
BudgetAdapter::guard();
throw new LogicException('Unconfigured unit accepted');
} catch (HttpException $error) {
$check($error->getStatusCode() === 403, 'unconfigured operational unit refused');
}
config(['purchasing-operational.scopes' => [implode(':', $scope)]]);
$db->statement('ALTER TABLE ir_requests ADD COLUMN unexpected_extension text');
try {
BudgetAdapter::guard();
throw new LogicException('Divergent schema accepted');
} catch (HttpException $error) {
$check($error->getStatusCode() === 503, 'divergent schema refused without rewriting it');
}
$db->statement('ALTER TABLE ir_requests DROP COLUMN unexpected_extension');
try {
DB::transaction(fn () => DB::table('sgc_installations')->delete());
throw new LogicException('Installation history deleted');
} catch (QueryException $error) {
$check(str_contains($error->getMessage(), 'Preserve installation history'), 'schema preparation history immutable');
}
$config = $db->getConfig();
$dump = new Process(['C:/Program Files/PostgreSQL/17/bin/pg_dump.exe', '--format=custom', '--file='.getenv('IR_OPERATIONAL_BACKUP'), '--dbname='.$db->getDatabaseName()], null,
['PGHOST' => $config['host'], 'PGPORT' => (string) $config['port'], 'PGUSER' => $config['username'], 'PGPASSWORD' => $config['password']], null, 60);
$dump->mustRun();
echo json_encode(['passed' => true, 'checks' => $checks, 'installed_on_destination' => false, 'activated' => false], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);