180 lines
11 KiB
PHP
180 lines
11 KiB
PHP
<?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);
|