commit13
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
const source = await fs.readFile('scripts/supplier-evaluation-check.mjs', 'utf8');
|
||||
const common = source.slice(source.indexOf('const base ='), source.indexOf('const author ='));
|
||||
await new Function('fs', 'assert', 'return (async()=>{' + common + `
|
||||
const viewer = client(); await viewer.enter('admin');
|
||||
const dashboard = await viewer.request('/dashboard');
|
||||
if (dashboard.page?.props.context?.enforced) await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'});
|
||||
const settings = await viewer.request('/administracao/parametrizacao');
|
||||
assert.equal(settings.status,200);
|
||||
assert.equal(settings.page.props.workflowTasks.department_head[0].key,'requisition.approve.department_head');
|
||||
assert.ok(settings.page.props.workflowTasks.warehouse_request_prepare);
|
||||
const warehouseDrafts = await viewer.request('/stock/requisicoes');
|
||||
assert.equal(warehouseDrafts.status,200);
|
||||
assert.equal(warehouseDrafts.page.props.ready,true);
|
||||
assert.equal(warehouseDrafts.page.props.fulfilmentReady,true);
|
||||
assert.equal(warehouseDrafts.page.props.canPrepare,false);
|
||||
const availability = await viewer.request('/stock/disponibilidade');
|
||||
assert.equal(availability.status,200);
|
||||
const notices = await viewer.request('/requisicoes/avisos');
|
||||
assert.equal(notices.status,200);
|
||||
assert.equal(notices.page.component,'workflow/notices');
|
||||
assert.equal(notices.page.props.ready,true);
|
||||
for (const view of ['tasks','outcomes','unread']) {
|
||||
const filtered = await viewer.request('/requisicoes/avisos?view='+view);
|
||||
assert.equal(filtered.status,200);
|
||||
assert.equal(filtered.page.props.view,view);
|
||||
}
|
||||
const list = await viewer.request('/requisicoes'); assert.equal(list.status,200);
|
||||
const record = list.page.props.requisitions.data[0];
|
||||
let detail = 'no existing requisition';
|
||||
if (record) {
|
||||
const response = await viewer.request('/requisicoes/'+record.id);
|
||||
assert.equal(response.status,200);
|
||||
assert.ok(Array.isArray(response.page.props.approvalRouting.people));
|
||||
detail = response.page.props.approvalRouting.status;
|
||||
}
|
||||
await fs.writeFile('docs/proofs/approval-routing-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,profile_task_catalogue:'passed',workflow_notices:'ready',existing_requisition_routing:detail,business_commands:false,accounts_or_grants_changed:false,visual_browser_review:false},null,2));
|
||||
console.log('Profile task catalogue and existing requisition routing verified.');
|
||||
` + '})()')(fs, assert);
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
use Tempo\Distribution\Release;
|
||||
|
||||
// Produces review artifacts, never installs, migrates, updates a release channel or changes pointers.
|
||||
$root = dirname(__DIR__);
|
||||
$runtime = [];
|
||||
$verification = [];
|
||||
$add = function (string $relative, array &$list) use ($root): void {
|
||||
$path = $root.'/'.$relative;
|
||||
if (! is_file($path) || is_link($path)) {
|
||||
throw new RuntimeException('Ficheiro de candidata inexistente ou link: '.$relative);
|
||||
}
|
||||
$list[$relative] = hash_file('sha256', $path);
|
||||
};
|
||||
foreach (['packages/purchasing', 'integrations/sgf/internal-requisitions'] as $directory) {
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root.'/'.$directory, FilesystemIterator::SKIP_DOTS)) as $file) {
|
||||
if ($file->isLink()) {
|
||||
throw new RuntimeException('Links recusados.');
|
||||
}
|
||||
if ($file->isFile()) {
|
||||
$add(str_replace('\\', '/', substr($file->getPathname(), strlen($root) + 1)), $runtime);
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (['app/Services/InternalRequisitionPort.php', 'app/Services/RequisitionWorkflow.php', 'app/Models/Requisition.php', 'app/Providers/AppServiceProvider.php', 'app/Http/Requests/StoreRequisitionRequest.php', 'app/Http/Controllers/RequisitionController.php', 'app/Http/Middleware/HandleInertiaRequests.php', 'resources/js/pages/requisitions/form.tsx', 'resources/js/pages/requisitions/show.tsx', 'resources/js/types/procurement.ts', 'database/migrations/2026_11_01_000046_add_requisition_command_versions.php'] as $file) {
|
||||
$add($file, $runtime);
|
||||
}
|
||||
foreach (['scripts/internal-requisitions-proof.php', 'scripts/internal-requisitions-sgf-proof.php', 'scripts/internal-requisition-screen-check.mjs', 'tests/Support/internal-requisition-sgf-router.php', 'tests/Support/internal-requisition-sgf-worker.php', 'tests/Support/internal-requisition-sgf-restored.php', 'tests/Feature/InternalRequisitionCandidateTest.php'] as $file) {
|
||||
$add($file, $verification);
|
||||
}
|
||||
ksort($runtime);
|
||||
ksort($verification);
|
||||
$hash = hash('sha256', json_encode([$runtime, $verification], JSON_THROW_ON_ERROR));
|
||||
$id = 'IR-CANDIDATE-'.substr($hash, 0, 16);
|
||||
$destination = $root.'/storage/app/private/candidates/'.$id;
|
||||
if (! is_dir($destination)) {
|
||||
mkdir($destination, 0700, true);
|
||||
}
|
||||
$manifest = ['format' => 'tempo.integration-candidate.v1', 'candidate_id' => $id, 'source_sha256' => $hash, 'installable_by_code_updater' => false, 'activation_authorized' => false,
|
||||
'destination_version_unchanged' => '0.1.1', 'context' => 'procurement.internal', 'scope' => 'draft -> submit -> independent approval -> consultation',
|
||||
'validated_scope_excludes' => ['people.third_party', 'equipment/spare-part technical workflows', 'supplier sourcing and purchase orders'],
|
||||
'host_schema_changes' => ['compras' => 'baseline 45 + additive migration 46', 'sgf' => 'ir-v1 + permission column widening/private catalogue check extension; no grants'],
|
||||
'rollback' => ['code' => 'separate pointer mechanism; candidate not selected', 'schema_data' => 'transactional DDL, compatible additive column and backup/restore tested separately; no automatic down/restore'],
|
||||
'runtime_files' => $runtime, 'verification_files' => $verification];
|
||||
$manifestText = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)."\n";
|
||||
foreach ($runtime + $verification as $file => $expected) {
|
||||
$target = $destination.'/'.$file;
|
||||
if (is_file($target)) {
|
||||
if (hash_file('sha256', $target) !== $expected) {
|
||||
throw new RuntimeException('Candidata imutável divergente.');
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! is_dir(dirname($target))) {
|
||||
mkdir(dirname($target), 0700, true);
|
||||
}
|
||||
if (! copy($root.'/'.$file, $target) || hash_file('sha256', $target) !== $expected) {
|
||||
throw new RuntimeException('Origem mudou durante a preparação.');
|
||||
}
|
||||
}
|
||||
if (is_file($destination.'/manifest.json') && file_get_contents($destination.'/manifest.json') !== $manifestText) {
|
||||
throw new RuntimeException('Manifesto imutável divergente.');
|
||||
}
|
||||
file_put_contents($destination.'/manifest.json', $manifestText);
|
||||
$archives = [];
|
||||
foreach (['runtime' => $runtime, 'verification' => $verification] as $kind => $files) {
|
||||
$archive = $destination.'/'.$kind.'.zip';
|
||||
if (! is_file($archive)) {
|
||||
$zip = new ZipArchive;
|
||||
if ($zip->open($archive, ZipArchive::CREATE | ZipArchive::EXCL) !== true) {
|
||||
throw new RuntimeException('Falha no arquivo da candidata.');
|
||||
}
|
||||
foreach ($files as $file => $expected) {
|
||||
$zip->addFile($destination.'/'.$file, $file);
|
||||
}
|
||||
$zip->addFromString('manifest.json', $manifestText);
|
||||
if (! $zip->close()) {
|
||||
throw new RuntimeException('Arquivo incompleto.');
|
||||
}
|
||||
}
|
||||
$archives[$kind] = ['file' => $archive, 'sha256' => hash_file('sha256', $archive)];
|
||||
}
|
||||
require_once $root.'/packages/module-updates/Release.php';
|
||||
$rejected = false;
|
||||
try {
|
||||
Release::inspect($archives['runtime']['file'], hash('sha256', $manifestText));
|
||||
} catch (RuntimeException $e) {
|
||||
$rejected = true;
|
||||
}
|
||||
if (! $rejected) {
|
||||
throw new RuntimeException('A candidata não deve ser instalável pelo updater de código.');
|
||||
}
|
||||
$result = ['candidate_id' => $id, 'directory' => $destination, 'manifest_sha256' => hash('sha256', $manifestText), 'archives' => $archives, 'standard_updater_refuses_candidate' => true, 'activation_performed' => false];
|
||||
file_put_contents($root.'/docs/proofs/internal-requisitions-candidate.json', json_encode($result,JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($result,JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)."\n";
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import {
|
||||
actionsFor,
|
||||
commandEnvelope,
|
||||
limitMinor,
|
||||
limitMajor,
|
||||
} from '../integrations/sgf/internal-requisitions/screen.mjs';
|
||||
|
||||
const own = { requester_id: 1, status: 'draft', steps: [] };
|
||||
assert.deepEqual(actionsFor(own, 1, { prepare: true }), ['edit', 'submit']);
|
||||
assert.deepEqual(actionsFor(own, 2, { prepare: true }), []);
|
||||
const pending = {
|
||||
...own,
|
||||
status: 'pending',
|
||||
steps: [
|
||||
{ role: 'department_head', decision: 'pending', approver_id: null },
|
||||
],
|
||||
};
|
||||
assert.deepEqual(actionsFor(pending, 1, { department_head: true }), []);
|
||||
assert.deepEqual(actionsFor(pending, 2, { department_head: true }), [
|
||||
'approved',
|
||||
'rejected',
|
||||
]);
|
||||
assert.deepEqual(actionsFor(pending, 2, {}), []);
|
||||
assert.deepEqual(
|
||||
actionsFor(
|
||||
{
|
||||
...pending,
|
||||
steps: [
|
||||
{
|
||||
role: 'department_head',
|
||||
decision: 'approved',
|
||||
approver_id: 2,
|
||||
},
|
||||
{ role: 'finance', decision: 'pending' },
|
||||
],
|
||||
},
|
||||
2,
|
||||
{ finance: true },
|
||||
),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
actionsFor({ ...pending, status: 'approved' }, 2, {
|
||||
department_head: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const key = '12345678-1234-4234-8234-123456789012';
|
||||
assert.deepEqual(commandEnvelope(key, 3, { decision: 'approved' }), {
|
||||
operation_key: key,
|
||||
version: 3,
|
||||
decision: 'approved',
|
||||
});
|
||||
assert.equal(limitMinor('1000.01'), 100001);
|
||||
assert.equal(limitMajor(100001), '1000.01');
|
||||
assert.equal(limitMinor('1000000000000.00'), 100000000000000);
|
||||
assert.throws(() => limitMinor('1.001'));
|
||||
assert.throws(() => limitMinor('1000000000000.01'));
|
||||
const html = await fs.readFile(
|
||||
new URL(
|
||||
'../integrations/sgf/internal-requisitions/screen.php',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
for (const text of [
|
||||
'Guardar rascunho',
|
||||
'Histórico por versão',
|
||||
'Centro de custo',
|
||||
'Parametrização',
|
||||
'Compras internas',
|
||||
'ir-contract',
|
||||
])
|
||||
assert.ok(html.includes(text));
|
||||
const script = await fs.readFile(
|
||||
new URL(
|
||||
'../integrations/sgf/internal-requisitions/screen.mjs',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(!script.includes('innerHTML'));
|
||||
for (const header of ['X-CSRF-TOKEN', 'X-SGF-Context', 'X-Purchasing-Context'])
|
||||
assert.ok(script.includes(header));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
passed: true,
|
||||
checks: 23,
|
||||
browser_used: false,
|
||||
scope: 'screen action model, exact limits, retry envelope, safe text rendering and context headers',
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$config = DB::connection('pgsql')->getConfig();
|
||||
unset($config['name']);
|
||||
if ($config['host'] !== '127.0.0.1') {
|
||||
throw new LogicException('Provas apenas no PostgreSQL local isolado.');
|
||||
}
|
||||
config(['database.connections.ir_proof_admin' => [...$config, 'url' => null, 'database' => 'postgres']]);
|
||||
$admin = DB::connection('ir_proof_admin');
|
||||
$suffix = bin2hex(random_bytes(6));
|
||||
$database = 'budget_core_sgf_'.$suffix;
|
||||
$role = 'ir_proof_'.$suffix;
|
||||
$password = bin2hex(random_bytes(32));
|
||||
$work = sys_get_temp_dir().'/sgc-ir-proof-'.$suffix;
|
||||
mkdir($work, 0700);
|
||||
mkdir($work.'/sessions', 0700);
|
||||
mkdir($work.'/cache', 0700);
|
||||
$host = realpath(dirname(__DIR__).'/../sgf-tempo');
|
||||
$candidate = getenv('IR_CANDIDATE_ROOT') ?: dirname(__DIR__);
|
||||
$before = json_decode(file_get_contents($host.'/packages/purchasing/current.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
$created = false;
|
||||
$roleCreated = false;
|
||||
$restore = null;
|
||||
$restoreCreated = false;
|
||||
$sourceDatabase = null;
|
||||
$sourceCreated = false;
|
||||
$exitCode = 0;
|
||||
try {
|
||||
if (! preg_match('/^budget_core_sgf_[a-f0-9]{12}$/D', $database) || ! preg_match('/^ir_proof_[a-f0-9]{12}$/D', $role)) {
|
||||
throw new LogicException('Identidade descartável inválida.');
|
||||
}
|
||||
$admin->statement('CREATE ROLE "'.$role.'" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '.$admin->getPdo()->quote($password));
|
||||
$roleCreated = true;
|
||||
$admin->statement('CREATE DATABASE "'.$database.'" OWNER "'.$role.'"');
|
||||
$created = true;
|
||||
$env = ['APP_ENV' => 'budget-testing', 'APP_DEBUG' => 'false', 'APP_KEY' => 'base64:'.base64_encode(random_bytes(32)), 'APP_URL' => 'http://127.0.0.1', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_HOST' => $config['host'], 'DB_PORT' => (string) $config['port'], 'DB_DATABASE' => $database, 'DB_USERNAME' => $role, 'DB_PASSWORD' => $password,
|
||||
'SESSION_DRIVER' => 'file', 'SESSION_FILES' => $work.'/sessions', 'SESSION_COOKIE' => 'sgf_ir_'.$suffix, 'CACHE_STORE' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array', 'BCRYPT_ROUNDS' => '4',
|
||||
'IR_SGF_HOST' => $host, 'IR_CANDIDATE_ROOT' => $candidate, 'IR_FAULT_FILE' => $work.'/fault.flag', 'IR_WORK_DIR' => $work];
|
||||
// The project wrapper is the authorised entry to the pinned host PHP.
|
||||
$process = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $host.'/scripts/sgf.ps1', 'php', $candidate.'/scripts/internal-requisitions-sgf-proof.php'], $host, $env, null, 120);
|
||||
$process->run();
|
||||
if (! $process->isSuccessful()) {
|
||||
echo $process->getOutput();
|
||||
echo $process->getErrorOutput();
|
||||
throw new RuntimeException('Prova SGF da candidata falhou.');
|
||||
}
|
||||
try {
|
||||
$proof = json_decode($process->getOutput(), true, 64, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException $e) {
|
||||
file_put_contents($work.'/failure.txt', $process->getOutput().$process->getErrorOutput());
|
||||
if (preg_match('/Candidate proof failed: [^\r\n]+/', $process->getOutput(), $diagnostic)) {
|
||||
echo $diagnostic[0]."\n";
|
||||
} else {
|
||||
echo 'Saída da prova não é JSON; diagnóstico privado em '.$work.'/failure.txt'."\n";
|
||||
}
|
||||
throw new RuntimeException('A prova filha não concluiu as verificações.');
|
||||
}
|
||||
$after = json_decode(file_get_contents($host.'/packages/purchasing/current.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
if ($before !== $after || $after['version'] !== '0.1.1') {
|
||||
throw new RuntimeException('A instalação activa não devia mudar.');
|
||||
}
|
||||
$qa = [...$config, 'url' => null, 'database' => $database, 'username' => $role, 'password' => $password];
|
||||
config(['database.connections.ir_proof_read' => $qa]);
|
||||
$db = DB::connection('ir_proof_read');
|
||||
$tables = array_map(fn ($r) => $r->tablename, $db->select("select tablename from pg_tables where schemaname='public' order by tablename"));
|
||||
$fingerprint = function ($connection) use ($tables) {
|
||||
$out = [];
|
||||
foreach ($tables as $table) {
|
||||
$quoted = '"'.str_replace('"', '""', $table).'"';
|
||||
$rows = $connection->select('select to_jsonb(t)::text as value from '.$quoted.' t order by to_jsonb(t)::text');
|
||||
$out[$table] = hash('sha256', json_encode(array_column($rows, 'value'), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
return $out;
|
||||
};
|
||||
$baseline = $fingerprint($db);
|
||||
$dump = $work.'/recovery.dump';
|
||||
$bin = 'C:/Program Files/PostgreSQL/17/bin/';
|
||||
$pgEnv = ['PGHOST' => $config['host'], 'PGPORT' => (string) $config['port'], 'PGUSER' => $role, 'PGPASSWORD' => $password];
|
||||
$backup = new Process([$bin.'pg_dump.exe', '--format=custom', '--file='.$dump, '--dbname='.$database], null, $pgEnv, null, 60);
|
||||
$backup->mustRun();
|
||||
$restore = 'budget_core_sgf_'.bin2hex(random_bytes(6));
|
||||
$admin->statement('CREATE DATABASE "'.$restore.'" OWNER "'.$role.'"');
|
||||
$restoreCreated = true;
|
||||
$restoreProcess = new Process([$bin.'pg_restore.exe', '--no-owner', '--no-privileges', '--exit-on-error', '--dbname='.$restore, $dump], null, $pgEnv, null, 60);
|
||||
$restoreProcess->mustRun();
|
||||
config(['database.connections.ir_proof_restore' => [...$qa, 'database' => $restore]]);
|
||||
$restored = DB::connection('ir_proof_restore');
|
||||
if ($fingerprint($restored) !== $baseline) {
|
||||
throw new RuntimeException('Dados restaurados diferentes da prova.');
|
||||
}
|
||||
$readRestore = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $host.'/scripts/sgf.ps1', 'php', $candidate.'/tests/Support/internal-requisition-sgf-restored.php'], $host, [...$env, 'DB_DATABASE' => $restore], null, 30);
|
||||
$readRestore->mustRun();
|
||||
$restoredResult = json_decode($readRestore->getOutput(), true, 32, JSON_THROW_ON_ERROR);
|
||||
if ($fingerprint($restored) !== $baseline) {
|
||||
throw new RuntimeException('Consulta alterou os dados restaurados.');
|
||||
}
|
||||
$schemaSignature = function ($connection) {
|
||||
return json_encode([
|
||||
$connection->select("select table_name,column_name,data_type,character_maximum_length,is_nullable,ordinal_position from information_schema.columns where table_schema='public' order by table_name,ordinal_position"),
|
||||
$connection->select("select c.relname,t.tgname,pg_get_triggerdef(t.oid) as definition from pg_trigger t join pg_class c on c.oid=t.tgrelid join pg_namespace n on n.oid=c.relnamespace where n.nspname='public' and not t.tgisinternal order by c.relname,t.tgname"),
|
||||
$connection->select("select c.relname,k.conname,pg_get_constraintdef(k.oid) as definition from pg_constraint k join pg_class c on c.oid=k.conrelid join pg_namespace n on n.oid=c.relnamespace where n.nspname='public' order by c.relname,k.conname"),
|
||||
], JSON_THROW_ON_ERROR);
|
||||
};
|
||||
if ($schemaSignature($db) !== $schemaSignature($restored)) {
|
||||
file_put_contents($work.'/schema-before.json', $schemaSignature($db));
|
||||
file_put_contents($work.'/schema-after.json', $schemaSignature($restored));
|
||||
echo 'Schema diagnostic: '.$work."\n";
|
||||
throw new RuntimeException('Schema restaurado diferente da prova.');
|
||||
}
|
||||
$proof['data_schema_recovery'] = ['backup_sha256' => hash_file('sha256', $dump), 'tables_reconciled' => count($tables), 'restore_verified' => true, 'schema_constraints_and_triggers_verified' => true, 'restored_consultation' => $restoredResult, 'restores_new_operations_automatically' => false, 'separate_from_code_rollback' => true];
|
||||
$sourceDatabase = 'compras_receipts_test_'.bin2hex(random_bytes(6));
|
||||
$admin->statement('CREATE DATABASE "'.$sourceDatabase.'" OWNER "'.$role.'"');
|
||||
$sourceCreated = true;
|
||||
$sourceEnv = ['APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_HOST' => $config['host'], 'DB_PORT' => (string) $config['port'], 'DB_DATABASE' => $sourceDatabase, 'DB_USERNAME' => $role, 'DB_PASSWORD' => $password];
|
||||
$sourceTest = new Process([PHP_BINARY, 'vendor/bin/pest', '--compact', 'tests/Feature/InternalRequisitionCandidateTest.php', 'tests/Feature/ProcurementTest.php', 'tests/Feature/BudgetTest.php'], dirname(__DIR__), $sourceEnv, null, 120);
|
||||
$sourceTest->mustRun();
|
||||
$proof['source_postgres_tests'] = ['passed' => true, 'output' => trim($sourceTest->getOutput())];
|
||||
$proof['checked_at'] = gmdate('c');
|
||||
$proof['isolated_database_removed_in_finally'] = true;
|
||||
$proof['active_destination_version'] = $after['version'];
|
||||
$proof['candidate_root'] = $candidate;
|
||||
file_put_contents(dirname(__DIR__).'/docs/proofs/internal-requisitions-sgf.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
|
||||
} catch (Throwable $error) {
|
||||
fwrite(STDERR, $error->getMessage()."\n");
|
||||
$exitCode = 1;
|
||||
} finally {
|
||||
if ($sourceCreated) {
|
||||
$admin->select('select pg_terminate_backend(pid) from pg_stat_activity where datname=? and pid<>pg_backend_pid()', [$sourceDatabase]);
|
||||
$admin->statement('DROP DATABASE "'.$sourceDatabase.'"');
|
||||
}
|
||||
if ($restoreCreated) {
|
||||
DB::purge('ir_proof_restore');
|
||||
$admin->select('select pg_terminate_backend(pid) from pg_stat_activity where datname=? and pid<>pg_backend_pid()', [$restore]);
|
||||
$admin->statement('DROP DATABASE "'.$restore.'"');
|
||||
}
|
||||
DB::purge('ir_proof_read');
|
||||
if ($created) {
|
||||
$admin->select('select pg_terminate_backend(pid) from pg_stat_activity where datname=? and pid<>pg_backend_pid()', [$database]);
|
||||
$admin->statement('DROP DATABASE "'.$database.'"');
|
||||
}
|
||||
if ($roleCreated) {
|
||||
$admin->statement('DROP ROLE "'.$role.'"');
|
||||
}
|
||||
// Only the generated workspace is eligible for cleanup; retained on proof failure for diagnosis.
|
||||
}
|
||||
exit($exitCode);
|
||||
@@ -0,0 +1,259 @@
|
||||
<?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);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const source = await fs.readFile('scripts/supplier-evaluation-check.mjs', 'utf8');
|
||||
const common = source.slice(source.indexOf('const base ='), source.indexOf('const author ='));
|
||||
const code = common + `
|
||||
const viewer = client(); await viewer.enter('admin');
|
||||
const dashboard = await viewer.request('/dashboard');
|
||||
if (dashboard.page?.props.context?.enforced) {
|
||||
if (dashboard.page.props.context.available.includes('people.third_party')) {
|
||||
await viewer.request('/contexto', 'POST', {area:'people', option:'people.third_party'});
|
||||
const pending = await viewer.request('/pessoas/relatorios');
|
||||
assert.equal(pending.status,302);
|
||||
assert.ok(pending.location.endsWith('/dashboard'));
|
||||
}
|
||||
const result = await viewer.request('/contexto', 'POST', {area:'people', option:'people.internal'});
|
||||
assert.ok([302,303].includes(result.status));
|
||||
}
|
||||
const result = await viewer.request('/pessoas/relatorios');
|
||||
assert.equal(result.status,200);
|
||||
assert.equal(result.page.component,'people/reports');
|
||||
assert.ok(Array.isArray(result.page.props.summary));
|
||||
const applications = await viewer.request('/pessoas/relatorios?view=applications');
|
||||
assert.equal(applications.status,200);
|
||||
assert.ok(applications.page.props.applicationMetrics !== undefined);
|
||||
const applicationsCsv = await viewer.request('/pessoas/relatorios.csv?view=applications','GET',undefined,false);
|
||||
assert.equal(applicationsCsv.status,200);
|
||||
assert.ok(applicationsCsv.text.includes('Registadas para avaliação'));
|
||||
const selections = await viewer.request('/pessoas/relatorios?view=selections');
|
||||
assert.equal(selections.status,200);
|
||||
assert.ok(selections.page.props.selectionMetrics !== undefined);
|
||||
const selectionsCsv = await viewer.request('/pessoas/relatorios.csv?view=selections','GET',undefined,false);
|
||||
assert.equal(selectionsCsv.status,200);
|
||||
assert.ok(selectionsCsv.text.includes('Situação da selecção'));
|
||||
const deadlines = await viewer.request('/pessoas/relatorios?view=deadlines');
|
||||
assert.equal(deadlines.status,200);
|
||||
assert.ok(Array.isArray(deadlines.page.props.deadlineReport.items));
|
||||
const deadlinesCsv = await viewer.request('/pessoas/relatorios.csv?view=deadlines','GET',undefined,false);
|
||||
assert.equal(deadlinesCsv.status,200);
|
||||
assert.ok(deadlinesCsv.text.includes('Dias de atraso'));
|
||||
for (const window of ['overdue','today','future','undated']) {
|
||||
const filtered = await viewer.request('/pessoas/relatorios?view=deadlines&deadline_window='+window);
|
||||
assert.equal(filtered.status,200);
|
||||
assert.equal(filtered.page.props.deadlineReport.window,window);
|
||||
assert.ok(filtered.page.props.deadlineReport.pagination);
|
||||
}
|
||||
const csv = await viewer.request('/pessoas/relatorios.csv','GET',undefined,false);
|
||||
assert.equal(csv.status,200);
|
||||
assert.ok(csv.text.includes('Referência'));
|
||||
await fs.writeFile('docs/proofs/people-reports-http.json',JSON.stringify({
|
||||
checked_at:new Date().toISOString(), environment:base,
|
||||
pages:[{path:'/pessoas/relatorios',status:result.status},{path:'/pessoas/relatorios.csv',status:csv.status},
|
||||
{path:'/pessoas/relatorios?view=applications',status:applications.status},{path:'/pessoas/relatorios.csv?view=applications',status:applicationsCsv.status},
|
||||
{path:'/pessoas/relatorios?view=selections',status:selections.status},{path:'/pessoas/relatorios.csv?view=selections',status:selectionsCsv.status},
|
||||
{path:'/pessoas/relatorios?view=deadlines',status:deadlines.status},{path:'/pessoas/relatorios.csv?view=deadlines',status:deadlinesCsv.status}],
|
||||
business_commands:false, accounts_or_grants_changed:false, visual_browser_review:false
|
||||
},null,2));
|
||||
console.log('People report and CSV verified in local evaluation.');
|
||||
`;
|
||||
await new Function('fs', 'assert', 'return (async()=>{'+code+'})()')(fs, assert);
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Tempo\Purchasing\Delivery\AccessOverlay;
|
||||
|
||||
require dirname(__DIR__).'/integrations/sgf/access/AccessOverlay.php';
|
||||
|
||||
$host = realpath($argv[1] ?? dirname(__DIR__).'/../sgf-tempo');
|
||||
if (! $host) {
|
||||
throw new RuntimeException('Host inexistente.');
|
||||
}
|
||||
$files = AccessOverlay::prepare($host);
|
||||
$support = [];
|
||||
foreach (['InitialAuthority.php', 'AccessOverlay.php', 'routes.php', 'screen.php'] as $name) {
|
||||
$support[$name] = file_get_contents(dirname(__DIR__).'/integrations/sgf/access/'.$name);
|
||||
}
|
||||
$hash = hash('sha256', json_encode([$files, $support], JSON_THROW_ON_ERROR));
|
||||
$directory = dirname(__DIR__).'/storage/app/private/candidates/ACCESS-CANDIDATE-'.substr($hash, 0, 16);
|
||||
$manifest = ['format' => 'tempo.host-overlay.review.v1', 'activation_authorized' => false, 'installed' => false, 'host_version_preserved' => '0.1.1', 'files' => []];
|
||||
foreach ($files as $path => $file) {
|
||||
$target = $directory.'/'.$path;
|
||||
if (! is_dir(dirname($target))) {
|
||||
mkdir(dirname($target), 0700, true);
|
||||
}
|
||||
if (is_file($target) && hash_file('sha256', $target) !== $file['after_sha256']) {
|
||||
throw new RuntimeException('Artefacto imutável divergente.');
|
||||
}
|
||||
file_put_contents($target, $file['contents']);
|
||||
unset($file['contents']);
|
||||
$manifest['files'][$path] = $file;
|
||||
}
|
||||
foreach ($support as $name => $content) {
|
||||
if (is_file($directory.'/'.$name) && hash_file('sha256', $directory.'/'.$name) !== hash('sha256', $content)) {
|
||||
throw new RuntimeException('Artefacto de acesso imutável divergente.');
|
||||
}
|
||||
file_put_contents($directory.'/'.$name, $content);
|
||||
$manifest['support_files'][$name] = hash('sha256', $content);
|
||||
}
|
||||
file_put_contents($directory.'/manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode(['directory' => $directory, 'installed' => false], JSON_THROW_ON_ERROR)."\n";
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Tempo\Purchasing\Delivery\AccessOverlay;
|
||||
use Tempo\Purchasing\Delivery\BootOverlay;
|
||||
use Tempo\Purchasing\Delivery\BudgetOverlay;
|
||||
|
||||
require dirname(__DIR__).'/integrations/sgf/access/AccessOverlay.php';
|
||||
require dirname(__DIR__).'/integrations/sgf/operational/BudgetOverlay.php';
|
||||
require dirname(__DIR__).'/integrations/sgf/operational/BootOverlay.php';
|
||||
$host = realpath($argv[1] ?? dirname(__DIR__).'/../sgf-tempo');
|
||||
if (! $host) {
|
||||
throw new RuntimeException('Host inexistente.');
|
||||
}
|
||||
$files = AccessOverlay::prepare($host) + BudgetOverlay::prepare($host) + BootOverlay::prepare($host);
|
||||
$root = dirname(__DIR__);
|
||||
$support = [];
|
||||
foreach (['integrations/sgf/operational', 'integrations/sgf/access', 'integrations/sgf/internal-requisitions', 'packages/purchasing/src'] as $directory) {
|
||||
foreach (new DirectoryIterator($root.'/'.$directory) as $file) {
|
||||
if ($file->isFile() && ! $file->isLink()) {
|
||||
$support[$directory.'/'.$file->getFilename()] = file_get_contents($file->getPathname());
|
||||
}
|
||||
}
|
||||
}
|
||||
ksort($support);
|
||||
$support['scripts/sgf-internal-install.php'] = file_get_contents($root.'/scripts/sgf-internal-install.php');
|
||||
$support['bootstrap.php'] = file_get_contents($root.'/integrations/sgf/operational/entry.php');
|
||||
$hash = hash('sha256', json_encode([$files, $support], JSON_THROW_ON_ERROR));
|
||||
$directory = $root.'/storage/app/private/candidates/OPERATIONAL-CANDIDATE-'.substr($hash, 0, 16);
|
||||
$manifest = ['format' => 'tempo.operational-review.v1', 'installed' => false, 'activated' => false, 'host_version_preserved' => '0.1.1', 'host_overlays' => [], 'support_files' => []];
|
||||
foreach ($files as $path => $file) {
|
||||
$support[$path] = $file['contents'];
|
||||
unset($file['contents']);
|
||||
$manifest['host_overlays'][$path] = $file;
|
||||
}
|
||||
foreach ($support as $path => $contents) {
|
||||
$target = $directory.'/'.$path;
|
||||
if (! is_dir(dirname($target))) {
|
||||
mkdir(dirname($target), 0700, true);
|
||||
}
|
||||
if (is_file($target) && hash_file('sha256', $target) !== hash('sha256', $contents)) {
|
||||
throw new RuntimeException('Candidata imutável divergente.');
|
||||
}
|
||||
file_put_contents($target, $contents);
|
||||
$manifest['support_files'][$path] = hash('sha256', $contents);
|
||||
}
|
||||
file_put_contents($directory.'/manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode(['directory' => $directory, 'installed' => false], JSON_THROW_ON_ERROR)."\n";
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tempo\Distribution\Release;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
require_once dirname(__DIR__).'/packages/module-updates/Release.php';
|
||||
|
||||
$validation = null;
|
||||
$exitCode = 0;
|
||||
try {
|
||||
if (count($argv) !== 2) {
|
||||
throw new RuntimeException('Indique uma pasta de canal de distribuição.');
|
||||
}
|
||||
$source = dirname(__DIR__).'/packages/purchasing';
|
||||
$snapshot = function () use ($source): array {
|
||||
$hashes = [];
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS)) as $file) {
|
||||
if ($file->isFile()) {
|
||||
$hashes[$file->getPathname()] = hash_file('sha256', $file->getPathname());
|
||||
}
|
||||
}
|
||||
ksort($hashes);
|
||||
|
||||
return $hashes;
|
||||
};
|
||||
$before = $snapshot();
|
||||
$test = new Process([PHP_BINARY, 'artisan', 'test', '--compact', '--filter=PurchasingReleaseTest|PurchasingEconomicsTest|SupplierQuotationTest|PurchaseAwardOrderTest|ProposalOpeningTest|SupplyPlanningTest|SupplyHistoryTest|InventoryLotsTest|SupplyCoordinationTest|SupplySuggestionsTest|InventoryReversalsTest|InventoryCountsTest'], dirname(__DIR__), timeout: 120);
|
||||
$test->run();
|
||||
if (! $test->isSuccessful() || $snapshot() !== $before) {
|
||||
throw new RuntimeException('Publicação recusada: testes falharam ou a origem mudou durante a verificação.');
|
||||
}
|
||||
$validation = sys_get_temp_dir().'/sgc-purchasing-validation-'.bin2hex(random_bytes(8));
|
||||
mkdir($validation, 0700);
|
||||
$candidate = Release::publish($source, $validation);
|
||||
$probe = new Process([PHP_BINARY, 'scripts/purchasing-probe.php', $validation.'/'.$candidate['archive'], $candidate['manifest_sha256']], dirname(__DIR__), timeout: 30);
|
||||
$probe->mustRun();
|
||||
if ($snapshot() !== $before) {
|
||||
throw new RuntimeException('A origem mudou durante a prova candidata.');
|
||||
}
|
||||
// Publish the exact immutable snapshot exercised by the candidate probe.
|
||||
$checked = Release::inspect($validation.'/'.$candidate['archive'], $candidate['manifest_sha256']);
|
||||
$payload = $validation.'/payload';
|
||||
mkdir($payload, 0700);
|
||||
foreach ($checked['contents'] as $path => $content) {
|
||||
if (! is_dir(dirname($payload.'/'.$path))) {
|
||||
mkdir(dirname($payload.'/'.$path), 0700, true);
|
||||
}
|
||||
file_put_contents($payload.'/'.$path, $content);
|
||||
}
|
||||
$release = Release::publish($payload, $argv[1]);
|
||||
echo json_encode($release + ['tests' => trim($test->getOutput()), 'staged_source_probe' => json_decode($probe->getOutput(), true, 32, JSON_THROW_ON_ERROR)], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
|
||||
} catch (Throwable $error) {
|
||||
fwrite(STDERR, $error->getMessage()."\n");
|
||||
$exitCode = 1;
|
||||
} finally {
|
||||
if ($validation !== null && is_dir($validation)) {
|
||||
$real = realpath($validation);
|
||||
$temporary = str_replace('\\', '/', realpath(sys_get_temp_dir()));
|
||||
if ($real !== false && str_starts_with(str_replace('\\', '/', $real), $temporary.'/sgc-purchasing-validation-')) {
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($real, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) {
|
||||
if ($file->isDir() && ! $file->isLink()) {
|
||||
rmdir($file->getPathname());
|
||||
} else {
|
||||
unlink($file->getPathname());
|
||||
}
|
||||
}
|
||||
rmdir($real);
|
||||
}
|
||||
}
|
||||
}
|
||||
exit($exitCode);
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tempo\Distribution\Release;
|
||||
use Tempo\Purchasing\Supply\ReplenishmentProjection;
|
||||
|
||||
// Run this installed copy in the client host; no source checkout or application boot.
|
||||
$host = dirname(__DIR__);
|
||||
require $host.'/vendor/autoload.php';
|
||||
require $host.'/packages/purchasing/bootstrap.php';
|
||||
$current = Release::current($host);
|
||||
$policy = ['daily_milli' => 10000, 'safety_milli' => 20000, 'target_milli' => 100000, 'maximum_milli' => 120000, 'lead_days' => 4, 'approval_days' => 1, 'minimum_milli' => 24000, 'pack_milli' => 12000, 'horizon_days' => 30];
|
||||
$result = (new ReplenishmentProjection)->calculate($policy, 120000, '2026-11-01');
|
||||
if ($result['quantity_milli'] !== 84000) {
|
||||
throw new RuntimeException('O pacote instalado não preserva o resultado de referência.');
|
||||
}
|
||||
$base = strtolower(str_replace('\\', '/', realpath($host))).'/';
|
||||
foreach (get_included_files() as $file) {
|
||||
if (! str_starts_with(strtolower(str_replace('\\', '/', realpath($file))), $base)) {
|
||||
throw new RuntimeException('Foi carregado código fora da instalação cliente.');
|
||||
}
|
||||
}
|
||||
echo json_encode(['version' => $current['version'], 'runtime' => PHP_VERSION, 'all_loaded_code_in_client_host' => true,
|
||||
'source_checkout_required' => false, 'database_connected' => false, 'quantity_milli' => $result['quantity_milli']], JSON_THROW_ON_ERROR)."\n";
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tempo\Distribution\Release;
|
||||
use Tempo\Purchasing\Economic\ProposalScoring;
|
||||
use Tempo\Purchasing\Economic\QuotationPricing;
|
||||
use Tempo\Purchasing\Supply\ReplenishmentProjection;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
require_once dirname(__DIR__).'/packages/module-updates/Release.php';
|
||||
|
||||
$stage = null;
|
||||
$exitCode = 0;
|
||||
try {
|
||||
if (count($argv) !== 3) {
|
||||
throw new RuntimeException('Indique pacote e SHA-256 do manifesto.');
|
||||
}
|
||||
$checked = Release::inspect($argv[1], $argv[2]);
|
||||
$stage = sys_get_temp_dir().'/sgc-purchasing-probe-'.bin2hex(random_bytes(8));
|
||||
if (! mkdir($stage, 0700)) {
|
||||
throw new RuntimeException('Não foi possível preparar a prova isolada.');
|
||||
}
|
||||
foreach ($checked['contents'] as $path => $content) {
|
||||
if (! is_dir(dirname($stage.'/'.$path))) {
|
||||
mkdir(dirname($stage.'/'.$path), 0700, true);
|
||||
}
|
||||
file_put_contents($stage.'/'.$path, $content);
|
||||
}
|
||||
require $stage.'/bootstrap.php';
|
||||
$engine = new ReplenishmentProjection;
|
||||
$policy = ['daily_milli' => 10000, 'safety_milli' => 20000, 'target_milli' => 100000, 'maximum_milli' => 120000, 'lead_days' => 4, 'approval_days' => 1, 'minimum_milli' => 24000, 'pack_milli' => 12000, 'horizon_days' => 30];
|
||||
$result = $engine->calculate($policy, 120000, '2026-11-01');
|
||||
$rate = $engine->calculate([...$policy, 'horizon_days' => 3, 'daily_rate' => ['numerator_milli' => 1000, 'denominator_days' => 3]], 10000, '2026-11-01');
|
||||
$lots = $engine->calculate([...$policy, 'horizon_days' => 2], 10000, '2026-11-01', [], [], 0, [], [['quantity' => 10000, 'last_usable_on' => '2026-11-01']]);
|
||||
if ($result['risk_date'] !== '2026-11-10' || $result['quantity_milli'] !== 84000 || array_column($rate['series'], 'demand_milli') !== [334, 333, 333]
|
||||
|| $lots['series'][1]['expired_milli'] !== 0 || $lots['series'][1]['balance_milli'] !== -10000) {
|
||||
throw new RuntimeException('A versão candidata não preserva os resultados do contrato de integração.');
|
||||
}
|
||||
$scenarios = ['manual', 'exact_historical_rate', 'lot_expiry'];
|
||||
if (in_array('procurement.unit_discounts', $checked['manifest']['module']['capabilities'], true)) {
|
||||
$pricing = new QuotationPricing;
|
||||
if ($pricing->unit('19.99', 'percent', '12.50')['net_unit_minor'] !== 1749 || $pricing->unit('0.03', 'percent', '50')['net_unit_minor'] !== 2) {
|
||||
throw new RuntimeException('Descontos incompatíveis com o contrato.');
|
||||
}
|
||||
$scenarios[] = 'unit_discounts';
|
||||
}
|
||||
if (in_array('procurement.proposal_scoring', $checked['manifest']['module']['capabilities'], true)) {
|
||||
$row = ['eligible' => true, 'subtotal_minor' => 10000, 'delivery_days' => 2, 'weights' => ['price' => 50, 'quality' => 50, 'delivery' => 0, 'history' => 0, 'payment' => 0],
|
||||
'evaluation_count' => 2, 'qualified_evaluations' => array_fill(0, 2, ['quality' => '8', 'history' => '8', 'payment' => '8'])];
|
||||
$ranking = (new ProposalScoring)->rank([['id' => 1, ...$row], ['id' => 2, ...$row]], 2);
|
||||
if ($ranking !== ['scores' => [1 => 900, 2 => 900], 'top_ids' => [1, 2]]) {
|
||||
throw new RuntimeException('Pontuação ou empates incompatíveis com o contrato.');
|
||||
}
|
||||
$scenarios[] = 'proposal_scores_and_ties';
|
||||
}
|
||||
echo json_encode(['version' => $checked['manifest']['version'], 'source_sha256' => $checked['manifest']['source_sha256'], 'runtime' => PHP_VERSION, 'scenarios' => $scenarios, 'passed' => true, 'database_connected' => false], JSON_THROW_ON_ERROR)."\n";
|
||||
} catch (Throwable $error) {
|
||||
fwrite(STDERR, $error->getMessage()."\n");
|
||||
$exitCode = 1;
|
||||
} finally {
|
||||
if ($stage !== null && is_dir($stage)) {
|
||||
$real = realpath($stage);
|
||||
$temporary = str_replace('\\', '/', realpath(sys_get_temp_dir()));
|
||||
if ($real !== false && str_starts_with(str_replace('\\', '/', $real), $temporary.'/sgc-purchasing-probe-')) {
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($real, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) {
|
||||
if ($file->isDir() && ! $file->isLink()) {
|
||||
rmdir($file->getPathname());
|
||||
} else {
|
||||
unlink($file->getPathname());
|
||||
}
|
||||
}
|
||||
rmdir($real);
|
||||
}
|
||||
}
|
||||
}
|
||||
exit($exitCode);
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tempo\Distribution\Release;
|
||||
|
||||
// Identical tooling in both compositions; distribution artifacts are pinned copies.
|
||||
require_once dirname(__DIR__).'/packages/module-updates/Release.php';
|
||||
|
||||
try {
|
||||
$action = $argv[1] ?? '';
|
||||
if ($action === 'publish' && count($argv) === 4) {
|
||||
$result = Release::publish($argv[2], $argv[3]);
|
||||
} elseif ($action === 'inspect' && count($argv) === 4) {
|
||||
$result = Release::inspect($argv[2], $argv[3])['manifest'];
|
||||
} elseif ($action === 'install' && count($argv) === 5) {
|
||||
require_once dirname(__DIR__).'/vendor/autoload.php';
|
||||
$host = realpath($argv[2]);
|
||||
$archive = realpath($argv[3]);
|
||||
if ($host === false || $archive === false) {
|
||||
throw new RuntimeException('Instalação ou pacote inexistente.');
|
||||
}
|
||||
// Execute only the approved candidate, in an isolated PHP process, before switching.
|
||||
$probe = new Process([PHP_BINARY, dirname(__DIR__).'/scripts/purchasing-probe.php', $archive, $argv[4]], dirname(__DIR__), timeout: 30);
|
||||
$probe->mustRun();
|
||||
$result = Release::install($argv[2], $argv[3], $argv[4]);
|
||||
$result['runtime_probe'] = json_decode($probe->getOutput(), true, 32, JSON_THROW_ON_ERROR);
|
||||
} elseif ($action === 'status' && count($argv) === 3) {
|
||||
$result = Release::current($argv[2]) ?? ['installed' => false];
|
||||
} elseif ($action === 'rollback' && count($argv) === 3) {
|
||||
$result = Release::rollback($argv[2]);
|
||||
} elseif ($action === 'check' && count($argv) === 5) {
|
||||
$candidate = Release::inspect($argv[3], $argv[4])['manifest'];
|
||||
$current = Release::current($argv[2]);
|
||||
if ($current !== null && $current['version'] === $candidate['version'] && $current['manifest_sha256'] !== $argv[4]) {
|
||||
throw new RuntimeException('A mesma versão tem conteúdo diferente; reconcilie o canal antes de actualizar.');
|
||||
}
|
||||
$result = ['installed_version' => $current['version'] ?? null, 'candidate_version' => $candidate['version'],
|
||||
'available' => $current === null || version_compare($candidate['version'], $current['version'], '>'),
|
||||
'same_source' => ($current['source_sha256'] ?? null) === $candidate['source_sha256'],
|
||||
'database_changes' => false, 'permissions_changed' => false];
|
||||
} else {
|
||||
throw new RuntimeException('Uso: publish <pacote-origem> <canal>; inspect <zip> <sha-manifesto>; check <host> <zip> <sha-manifesto>; install <host> <zip> <sha-manifesto>; status <host>.');
|
||||
}
|
||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)."\n";
|
||||
} catch (Throwable $error) {
|
||||
fwrite(STDERR, $error->getMessage()."\n");
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$root = dirname(__DIR__);
|
||||
$host = realpath($root.'/../sgf-tempo');
|
||||
$config = DB::connection('pgsql')->getConfig();
|
||||
if ($config['host'] !== '127.0.0.1') {
|
||||
throw new RuntimeException('Prova apenas local.');
|
||||
}
|
||||
config(['database.connections.access_admin' => [...$config, 'url' => null, 'database' => 'postgres']]);
|
||||
$db = DB::connection('access_admin');
|
||||
$suffix = bin2hex(random_bytes(6));
|
||||
$role = 'ir_access_'.$suffix;
|
||||
$database = 'budget_core_sgf_'.$suffix;
|
||||
$password = bin2hex(random_bytes(32));
|
||||
$roleCreated = false;
|
||||
$databaseCreated = false;
|
||||
$code = 1;
|
||||
$before = file_get_contents($host.'/packages/purchasing/current.json');
|
||||
try {
|
||||
$prepare = new Process([PHP_BINARY, $root.'/scripts/prepare-sgf-access.php', $host], $root);
|
||||
$prepare->mustRun();
|
||||
$overlay = json_decode($prepare->getOutput(), true, 16, JSON_THROW_ON_ERROR)['directory'];
|
||||
$db->statement('CREATE ROLE "'.$role.'" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '.$db->getPdo()->quote($password));
|
||||
$roleCreated = true;
|
||||
$db->statement('CREATE DATABASE "'.$database.'" OWNER "'.$role.'"');
|
||||
$databaseCreated = true;
|
||||
$env = ['APP_ENV' => 'budget-testing', 'APP_DEBUG' => 'false', 'APP_KEY' => 'base64:'.base64_encode(random_bytes(32)), 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => (string) $config['port'], 'DB_DATABASE' => $database, 'DB_USERNAME' => $role, 'DB_PASSWORD' => $password, 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'IR_SGF_HOST' => $host, 'IR_CANDIDATE_ROOT' => $root, 'IR_ACCESS_OVERLAY' => $overlay];
|
||||
$process = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $host.'/scripts/sgf.ps1', 'php', $root.'/tests/Support/sgf-access-proof.php'], $host, $env, null, 60);
|
||||
$process->mustRun();
|
||||
$proof = json_decode($process->getOutput(), true, 32, JSON_THROW_ON_ERROR);
|
||||
if (! $proof['passed'] || file_get_contents($host.'/packages/purchasing/current.json') !== $before || json_decode($before, true)['version'] !== '0.1.1') {
|
||||
throw new RuntimeException('Prova ou preservação do destino falhou.');
|
||||
}
|
||||
$proof += ['overlay' => $overlay, 'checked_at' => gmdate('c'), 'destination_version' => '0.1.1', 'prior_tests_repeated' => false];
|
||||
$code = 0;
|
||||
} catch (Throwable $error) {
|
||||
fwrite(STDERR, $error->getMessage()."\n");
|
||||
} finally {
|
||||
if ($databaseCreated) {
|
||||
$db->select('select pg_terminate_backend(pid) from pg_stat_activity where datname=? and pid<>pg_backend_pid()', [$database]);
|
||||
$db->statement('DROP DATABASE "'.$database.'"');
|
||||
}
|
||||
if ($roleCreated) {
|
||||
$db->statement('DROP ROLE "'.$role.'"');
|
||||
}
|
||||
}
|
||||
if ($code === 0) {
|
||||
$proof['isolated_database_removed'] = true;
|
||||
file_put_contents($root.'/docs/proofs/sgf-access-delivery.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
|
||||
}
|
||||
exit($code);
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$root = dirname(__DIR__);
|
||||
$host = realpath($root.'/../sgf-tempo');
|
||||
$config = DB::connection('pgsql')->getConfig();
|
||||
if ($config['host'] !== '127.0.0.1') {
|
||||
throw new RuntimeException('Prova apenas local.');
|
||||
}
|
||||
config(['database.connections.access_admin' => [...$config, 'url' => null, 'database' => 'postgres']]);
|
||||
$db = DB::connection('access_admin');
|
||||
$suffix = bin2hex(random_bytes(6));
|
||||
$role = 'ir_composition_'.$suffix;
|
||||
$database = 'sgc_composition_'.$suffix;
|
||||
$password = bin2hex(random_bytes(32));
|
||||
$roleCreated = false;
|
||||
$databaseCreated = false;
|
||||
$code = 1;
|
||||
$before = file_get_contents($host.'/packages/purchasing/current.json');
|
||||
try {
|
||||
$prepare = new Process([PHP_BINARY, $root.'/scripts/prepare-sgf-operational.php', $host], $root);
|
||||
$prepare->mustRun();
|
||||
$overlay = json_decode($prepare->getOutput(), true, 16, JSON_THROW_ON_ERROR)['directory'];
|
||||
require $root.'/tests/Support/sgf-composition-clone.php';
|
||||
$clone = compositionClone($host, $overlay, $suffix);
|
||||
$db->statement('CREATE ROLE "'.$role.'" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '.$db->getPdo()->quote($password));
|
||||
$roleCreated = true;
|
||||
$db->statement('CREATE DATABASE "'.$database.'" OWNER "'.$role.'"');
|
||||
$databaseCreated = true;
|
||||
$env = ['APP_ENV' => 'production', 'APP_DEBUG' => 'false', 'APP_KEY' => 'base64:'.base64_encode(random_bytes(32)), 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => (string) $config['port'], 'DB_DATABASE' => $database, 'DB_USERNAME' => $role, 'DB_PASSWORD' => $password, 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'IR_SGF_HOST' => $host, 'IR_CANDIDATE_ROOT' => $root, 'IR_ACCESS_OVERLAY' => $overlay];
|
||||
$env += ['IR_CLONE' => $clone, 'SESSION_DRIVER' => 'file'];
|
||||
$env['SESSION_DRIVER'] = 'file';
|
||||
$env['SESSION_FILES'] = $clone.'/storage/framework/sessions';
|
||||
$env['SESSION_COOKIE'] = 'composition_'.$suffix;
|
||||
$env['APP_BASE_PATH'] = $clone;
|
||||
$process = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $host.'/scripts/sgf.ps1', 'php', $root.'/tests/Support/sgf-composition-proof.php'], $host, $env, null, 120);
|
||||
$process->mustRun();
|
||||
try {
|
||||
$proof = json_decode($process->getOutput(), true, 32, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException $error) {
|
||||
file_put_contents($clone.'/diagnostic.txt', $process->getOutput().$process->getErrorOutput());
|
||||
throw new RuntimeException('Diagnóstico privado: '.$clone.'/diagnostic.txt');
|
||||
}
|
||||
if (! $proof['passed'] || file_get_contents($host.'/packages/purchasing/current.json') !== $before || json_decode($before, true)['version'] !== '0.1.1') {
|
||||
throw new RuntimeException('Prova ou preservação do destino falhou.');
|
||||
}
|
||||
$proof += ['overlay' => $overlay, 'checked_at' => gmdate('c'), 'destination_version' => '0.1.1', 'prior_tests_repeated' => false];
|
||||
$code = 0;
|
||||
} catch (Throwable $error) {
|
||||
fwrite(STDERR, $error->getMessage()."\n");
|
||||
} finally {
|
||||
if ($databaseCreated) {
|
||||
$db->select('select pg_terminate_backend(pid) from pg_stat_activity where datname=? and pid<>pg_backend_pid()', [$database]);
|
||||
$db->statement('DROP DATABASE "'.$database.'"');
|
||||
}
|
||||
if ($roleCreated) {
|
||||
$db->statement('DROP ROLE "'.$role.'"');
|
||||
}
|
||||
}
|
||||
if ($code === 0) {
|
||||
$proof['isolated_database_removed'] = true;
|
||||
file_put_contents($root.'/docs/proofs/sgf-composition-delivery.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
|
||||
}
|
||||
exit($code);
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
// Executes only when explicitly invoked through the destination host's pinned PHP.
|
||||
// plan is read-only; migrate prepares schema only, never code/route activation or rights.
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tempo\Purchasing\Delivery\Installation;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$action = $argv[1] ?? '';
|
||||
$host = realpath($argv[2] ?? '');
|
||||
if (! in_array($action, ['plan', 'migrate'], true) || ! $host) {
|
||||
fwrite(STDERR, "Usage: plan <host> | migrate <host> <target-sha> <backup-file> <backup-sha> <operator> <manifest-sha>\n");
|
||||
exit(1);
|
||||
}
|
||||
try {
|
||||
$manifestText = file_get_contents($root.'/manifest.json');
|
||||
$manifest = json_decode($manifestText, true, 32, JSON_THROW_ON_ERROR);
|
||||
if ($manifest['format'] !== 'tempo.operational-review.v1') {
|
||||
throw new RuntimeException('Manifesto de preparação operacional obrigatório.');
|
||||
}
|
||||
foreach ($manifest['support_files'] as $file => $hash) {
|
||||
if (($file !== 'bootstrap.php' && ! preg_match('~^(app|packages|integrations|scripts)/[A-Za-z0-9_./-]+$~D', $file)) || str_contains($file, '..') || is_link($root.'/'.$file) || ! hash_equals($hash, hash_file('sha256', $root.'/'.$file))) {
|
||||
throw new RuntimeException('Integridade da entrega divergente.');
|
||||
}
|
||||
}
|
||||
foreach ($manifest['host_overlays'] as $file => $hashes) {
|
||||
$hash = hash_file('sha256', $host.'/'.$file);
|
||||
if (! in_array($hash, [$hashes['before_sha256'], $hashes['after_sha256']], true)) {
|
||||
throw new RuntimeException('Host alterado; reconciliar antes de preparar schema.');
|
||||
}
|
||||
}
|
||||
require $host.'/vendor/autoload.php';
|
||||
$app = require $host.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
require $root.'/integrations/sgf/operational/bootstrap.php';
|
||||
$db = DB::connection();
|
||||
if ($action === 'plan') {
|
||||
echo json_encode(['target_sha256' => Installation::target($db), 'manifest_sha256' => hash('sha256', $manifestText), 'schema_prepared' => $db->getSchemaBuilder()->hasTable('sgc_installations'), 'activated' => false], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
|
||||
} else {
|
||||
$backup = $argv[4] ?? '';
|
||||
$backupHash = $argv[5] ?? '';
|
||||
if (! is_file($backup) || ! hash_equals($backupHash, hash_file('sha256', $backup)) || ! hash_equals(hash('sha256', $manifestText), $argv[7] ?? '')) {
|
||||
throw new RuntimeException('Backup ou manifesto não conferidos.');
|
||||
}
|
||||
$index = new Process([getenv('PG_RESTORE_BINARY') ?: 'pg_restore', '--list', $backup], null, null, null, 60);
|
||||
$index->mustRun();
|
||||
echo json_encode(Installation::migrate($db, $argv[3] ?? '', $backupHash, $argv[6] ?? ''), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
|
||||
}
|
||||
} catch (Throwable $error) {
|
||||
fwrite(STDERR, $error->getMessage()."\n");
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$root = dirname(__DIR__);
|
||||
$host = realpath($root.'/../sgf-tempo');
|
||||
$config = DB::connection('pgsql')->getConfig();
|
||||
if ($config['host'] !== '127.0.0.1') {
|
||||
throw new RuntimeException('Prova apenas local.');
|
||||
}
|
||||
config(['database.connections.access_admin' => [...$config, 'url' => null, 'database' => 'postgres']]);
|
||||
$db = DB::connection('access_admin');
|
||||
$suffix = bin2hex(random_bytes(6));
|
||||
$role = 'ir_operational_'.$suffix;
|
||||
$database = 'budget_core_sgf_'.$suffix;
|
||||
$password = bin2hex(random_bytes(32));
|
||||
$roleCreated = false;
|
||||
$databaseCreated = false;
|
||||
$restoredCreated = false;
|
||||
$restoredDatabase = 'sgc_operational_'.$suffix;
|
||||
$recoveryDump = sys_get_temp_dir().'/sgc-operational-recovery-'.$suffix.'.dump';
|
||||
$code = 1;
|
||||
$before = file_get_contents($host.'/packages/purchasing/current.json');
|
||||
$calibrate = ($argv[1] ?? '') === 'calibrate';
|
||||
try {
|
||||
$prepare = new Process([PHP_BINARY, $root.'/scripts/prepare-sgf-operational.php', $host], $root);
|
||||
$prepare->mustRun();
|
||||
$overlay = json_decode($prepare->getOutput(), true, 16, JSON_THROW_ON_ERROR)['directory'];
|
||||
$db->statement('CREATE ROLE "'.$role.'" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '.$db->getPdo()->quote($password));
|
||||
$roleCreated = true;
|
||||
$db->statement('CREATE DATABASE "'.$database.'" OWNER "'.$role.'"');
|
||||
$databaseCreated = true;
|
||||
$env = ['APP_ENV' => 'budget-testing', 'APP_DEBUG' => 'false', 'APP_KEY' => 'base64:'.base64_encode(random_bytes(32)), 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => (string) $config['port'], 'DB_DATABASE' => $database, 'DB_USERNAME' => $role, 'DB_PASSWORD' => $password, 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'IR_SGF_HOST' => $host, 'IR_CANDIDATE_ROOT' => $root, 'IR_ACCESS_OVERLAY' => $overlay];
|
||||
$env['IR_CALIBRATE'] = $calibrate ? '1' : '0';
|
||||
$env['IR_OPERATIONAL_BACKUP'] = $recoveryDump;
|
||||
$process = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $host.'/scripts/sgf.ps1', 'php', $root.'/tests/Support/sgf-operational-proof.php'], $host, $env, null, 60);
|
||||
$process->mustRun();
|
||||
$proof = json_decode($process->getOutput(), true, 32, JSON_THROW_ON_ERROR);
|
||||
if (! $calibrate) {
|
||||
$db->statement('CREATE DATABASE "'.$restoredDatabase.'" OWNER "'.$role.'"');
|
||||
$restoredCreated = true;
|
||||
$restore = new Process(['C:/Program Files/PostgreSQL/17/bin/pg_restore.exe', '--no-owner', '--no-privileges', '--exit-on-error', '--dbname='.$restoredDatabase, $recoveryDump], null,
|
||||
['PGHOST' => '127.0.0.1', 'PGPORT' => (string) $config['port'], 'PGUSER' => $role, 'PGPASSWORD' => $password], null, 60);
|
||||
$restore->mustRun();
|
||||
$read = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $host.'/scripts/sgf.ps1', 'php', $root.'/tests/Support/sgf-operational-proof.php'], $host,
|
||||
[...$env, 'APP_ENV' => 'production', 'DB_DATABASE' => $restoredDatabase, 'IR_RESTORE_VERIFY' => '1'], null, 60);
|
||||
$read->mustRun();
|
||||
$proof['restored_operational_composition'] = json_decode($read->getOutput(), true, 32, JSON_THROW_ON_ERROR);
|
||||
$proof['recovery_dump_sha256'] = hash_file('sha256', $recoveryDump);
|
||||
}
|
||||
if (! $proof['passed'] || file_get_contents($host.'/packages/purchasing/current.json') !== $before || json_decode($before, true)['version'] !== '0.1.1') {
|
||||
throw new RuntimeException('Prova ou preservação do destino falhou.');
|
||||
}
|
||||
$proof += ['overlay' => $overlay, 'checked_at' => gmdate('c'), 'destination_version' => '0.1.1', 'prior_tests_repeated' => false];
|
||||
$code = 0;
|
||||
} catch (Throwable $error) {
|
||||
fwrite(STDERR, $error->getMessage()."\n");
|
||||
} finally {
|
||||
if ($restoredCreated) {
|
||||
$db->select('select pg_terminate_backend(pid) from pg_stat_activity where datname=? and pid<>pg_backend_pid()', [$restoredDatabase]);
|
||||
$db->statement('DROP DATABASE "'.$restoredDatabase.'"');
|
||||
}
|
||||
if ($databaseCreated) {
|
||||
$db->select('select pg_terminate_backend(pid) from pg_stat_activity where datname=? and pid<>pg_backend_pid()', [$database]);
|
||||
$db->statement('DROP DATABASE "'.$database.'"');
|
||||
}
|
||||
if ($roleCreated) {
|
||||
$db->statement('DROP ROLE "'.$role.'"');
|
||||
}
|
||||
}
|
||||
if ($code === 0) {
|
||||
if ($calibrate) {
|
||||
file_put_contents($root.'/integrations/sgf/operational/budget-contract.json', json_encode(['schema_signature' => $proof['schema_signature'], 'origin' => 'Existing accepted SGF common-budget host schema; no data or credentials'], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo "Budget schema contract recorded from isolated fixture, not operational validation.\n";
|
||||
exit(0);
|
||||
}
|
||||
$proof['isolated_database_removed'] = true;
|
||||
if (is_file($recoveryDump)) {
|
||||
unlink($recoveryDump);
|
||||
}
|
||||
file_put_contents($root.'/docs/proofs/sgf-operational-delivery.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
|
||||
}
|
||||
exit($code);
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
// Installs only the selected supply migration on the existing isolated local evaluation.
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
|
||||
$settings = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/payables-demo-compras.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
if (($settings['APP_ENV'] ?? '') !== 'payable-testing' || ($settings['DB_HOST'] ?? '') !== '127.0.0.1'
|
||||
|| ! preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D', $settings['DB_DATABASE'] ?? '', $match)
|
||||
|| ($settings['DB_USERNAME'] ?? '') !== 'payable_qa_'.$match[1]
|
||||
|| realpath($settings['HOST_ROOT'] ?? '') !== realpath(dirname(__DIR__))) {
|
||||
throw new LogicException('Apenas avaliação local isolada existente.');
|
||||
}
|
||||
$owner = DB::connection('pgsql')->getConfig();
|
||||
unset($owner['name']);
|
||||
if ($owner['host'] !== '127.0.0.1') {
|
||||
throw new LogicException('Restauro de controlo apenas local.');
|
||||
}
|
||||
$qa = [...$owner, 'url' => null, 'host' => $settings['DB_HOST'], 'port' => $settings['DB_PORT'] ?? '5432', 'database' => $settings['DB_DATABASE'], 'username' => $settings['DB_USERNAME'], 'password' => $settings['DB_PASSWORD']];
|
||||
config(['database.connections.supply_install' => $qa, 'database.connections.supply_owner' => [...$owner, 'database' => 'postgres', 'url' => null]]);
|
||||
$connection = DB::connection('supply_install');
|
||||
$identity = $connection->selectOne('select current_database() as db, current_user as role');
|
||||
if ($identity->db !== $settings['DB_DATABASE'] || $identity->role !== $settings['DB_USERNAME']) {
|
||||
throw new LogicException('Identidade do destino inesperada.');
|
||||
}
|
||||
$lotInstall = in_array('--lots', $argv ?? [], true);
|
||||
$countInstall = in_array('--counts', $argv ?? [], true);
|
||||
$contextsInstall = in_array('--contexts', $argv ?? [], true);
|
||||
$noticesInstall = in_array('--notices', $argv ?? [], true);
|
||||
$warehouseInstall = in_array('--warehouse-drafts', $argv ?? [], true);
|
||||
$fulfilmentInstall = in_array('--warehouse-events', $argv ?? [], true);
|
||||
if ((int) $lotInstall + (int) $countInstall + (int) $contextsInstall + (int) $noticesInstall + (int) $warehouseInstall + (int) $fulfilmentInstall > 1) {
|
||||
throw new LogicException('Seleccione uma única migração por instalação.');
|
||||
}
|
||||
$migration = $contextsInstall ? '2026_11_01_000045_add_work_context_configuration' : ($countInstall ? '2026_11_01_000044_allow_signed_lot_adjustments' : ($lotInstall ? '2026_11_01_000043_create_inventory_lot_allocations' : '2026_11_01_000042_create_supply_records'));
|
||||
$newTable = $countInstall || $contextsInstall ? null : ($lotInstall ? 'inventory_lot_allocations' : 'supply_records');
|
||||
if ($noticesInstall) {
|
||||
$migration = '2026_11_01_000046_create_workflow_notices';
|
||||
$newTable = 'workflow_notices';
|
||||
}
|
||||
if ($warehouseInstall) {
|
||||
$migration = '2026_11_01_000047_create_warehouse_requests';
|
||||
$newTable = 'warehouse_requests';
|
||||
}
|
||||
if ($fulfilmentInstall) {
|
||||
$migration = '2026_11_01_000048_create_warehouse_request_events';
|
||||
$newTable = 'warehouse_request_events';
|
||||
}
|
||||
if ($connection->table('migrations')->where('migration', $migration)->exists()) {
|
||||
echo "A migração de fornecimento já está instalada; nenhum registo alterado.\n";
|
||||
exit;
|
||||
}
|
||||
$tables = array_map(fn ($row) => $row->tablename, $connection->select("select tablename from pg_tables where schemaname='public' order by tablename"));
|
||||
function supplyFingerprint($db, array $tables, bool $contexts = false): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($tables as $table) {
|
||||
$quoted = '"'.str_replace('"', '""', $table).'"';
|
||||
$projection = $contexts ? "(to_jsonb(t) - ARRAY['work_context_policy','work_contexts','work_context_key'])::text" : 'row_to_json(t)::text';
|
||||
$rows = $db->select('select '.$projection.' as row from '.$quoted.' t order by '.$projection);
|
||||
$result[$table] = hash('sha256', json_encode(array_column($rows, 'row'), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
$baseline = supplyFingerprint($connection, $tables, $contextsInstall);
|
||||
$directory = 'C:/laragon/data/sgf-tempo/backups/compras-supply-'.gmdate('Ymd-His');
|
||||
if (! mkdir($directory, 0700, true)) {
|
||||
throw new RuntimeException('Não foi possível criar a pasta privada de backup.');
|
||||
}
|
||||
$dump = $directory.'/before.dump';
|
||||
$bin = 'C:/Program Files/PostgreSQL/17/bin/';
|
||||
function supplyPg(string $binary, array $arguments, array $config): void
|
||||
{
|
||||
$process = new Process([$binary, ...$arguments], null, ['PGHOST' => $config['host'], 'PGPORT' => (string) $config['port'], 'PGUSER' => $config['username'], 'PGPASSWORD' => $config['password']], timeout: 120);
|
||||
$process->run();
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new RuntimeException('Operação PostgreSQL de backup/restauro falhou; instalação interrompida.');
|
||||
}
|
||||
}
|
||||
supplyPg($bin.'pg_dump.exe', ['--format=custom', '--file='.$dump, '--dbname='.$qa['database']], $qa);
|
||||
$restore = 'compras_supply_restore_'.bin2hex(random_bytes(6));
|
||||
$admin = DB::connection('supply_owner');
|
||||
$admin->statement('CREATE DATABASE "'.$restore.'"');
|
||||
try {
|
||||
supplyPg($bin.'pg_restore.exe', ['--no-owner', '--no-privileges', '--exit-on-error', '--dbname='.$restore, $dump], $owner);
|
||||
config(['database.connections.supply_restore' => [...$owner, 'database' => $restore, 'url' => null]]);
|
||||
if (supplyFingerprint(DB::connection('supply_restore'), $tables, $contextsInstall) !== $baseline) {
|
||||
throw new RuntimeException('Restauro diferente da origem. Nenhuma migração aplicada.');
|
||||
}
|
||||
DB::setDefaultConnection('supply_install');
|
||||
$exit = Artisan::call('migrate', ['--database' => 'supply_install', '--path' => 'database/migrations/'.$migration.'.php', '--force' => true]);
|
||||
if ($exit !== 0) {
|
||||
throw new RuntimeException('Migração dirigida falhou.');
|
||||
}
|
||||
$preserved = array_values(array_diff($tables, ['migrations']));
|
||||
if (supplyFingerprint($connection, $preserved, $contextsInstall) !== array_intersect_key($baseline, array_flip($preserved)) || ($newTable !== null && $connection->table($newTable)->count() !== 0)) {
|
||||
throw new RuntimeException('Conferência posterior falhou; conservar backup e investigar.');
|
||||
}
|
||||
$proof = ['checked_at' => gmdate('c'), 'migration' => $migration, 'environment' => 'local evaluation 8005', 'backup_file' => $dump, 'backup_sha256' => hash_file('sha256', $dump), 'restore_verified' => true,
|
||||
'existing_tables_preserved' => count($preserved), 'new_tables' => $newTable === null ? [] : [$newTable => 0], 'accounts_or_grants_changed' => false, 'domain_installed' => false];
|
||||
if ($contextsInstall) {
|
||||
foreach (['requisitions' => 'procurement.internal', 'people_processes' => 'people.internal'] as $table => $key) {
|
||||
if ($connection->table($table)->where('work_context_key', '!=', $key)->exists()) {
|
||||
throw new RuntimeException('Classificação interna inesperada após migração.');
|
||||
}
|
||||
}
|
||||
$proof['existing_processes_classified_internal'] = true;
|
||||
$proof['commercial_policy_activated'] = false;
|
||||
}
|
||||
$proofName = $fulfilmentInstall ? 'warehouse-fulfilment-install' : ($warehouseInstall ? 'warehouse-drafts-install' : ($noticesInstall ? 'workflow-notices-install' : ($contextsInstall ? 'work-context-install' : ($countInstall ? 'inventory-count-install' : ($lotInstall ? 'inventory-lot-install' : 'supply-install')))));
|
||||
file_put_contents(dirname(__DIR__).'/docs/proofs/'.$proofName.'.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo 'Migração dirigida instalada; restauro conferido, '.count($preserved)." tabelas preservadas. Sem atribuições ou dados de negócio.\n";
|
||||
} finally {
|
||||
DB::purge('supply_restore');
|
||||
$admin->select('select pg_terminate_backend(pid) from pg_stat_activity where datname = ? and pid <> pg_backend_pid()', [$restore]);
|
||||
$admin->statement('DROP DATABASE "'.$restore.'"');
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
const source = await fs.readFile('scripts/supplier-evaluation-check.mjs', 'utf8');
|
||||
const common = source.slice(source.indexOf("const base ="), source.indexOf('const author ='));
|
||||
const code = common + `
|
||||
const viewer = client(); await viewer.enter('admin');
|
||||
const dashboard = await viewer.request('/dashboard');
|
||||
if (dashboard.page?.props.context?.enforced) {
|
||||
const selected = await viewer.request('/contexto', 'POST', {area:'procurement',option:'procurement.internal'});
|
||||
assert.ok([302,303].includes(selected.status));
|
||||
}
|
||||
const proof = [];
|
||||
for (const view of ['agreements','calendar','imports','alternatives','reservations','deliveries','suggestions','history']) {
|
||||
const result = await viewer.request('/compras/fornecimento?view='+view);
|
||||
assert.equal(result.status,200); assert.equal(result.page.component, ['suggestions','history'].includes(view)?'supply/'+view:['alternatives','reservations','deliveries'].includes(view)?'supply/coordination':'supply/index'); assert.equal(result.page.props.ready,true);
|
||||
assert.equal(result.page.props.canPrepare,false);
|
||||
proof.push({view,status:result.status,ready:result.page.props.ready,admin_has_no_automatic_write:true});
|
||||
}
|
||||
const lots = await viewer.request('/stock/lotes');
|
||||
assert.equal(lots.status,200); assert.equal(lots.page.component,'stock/lots'); assert.equal(lots.page.props.ready,true);
|
||||
assert.equal(lots.page.props.capabilities.read,true);
|
||||
for (const permission of ['prepare','review','hold','release']) assert.equal(lots.page.props.capabilities[permission],false);
|
||||
proof.push({view:'lots',status:lots.status,ready:true,admin_has_no_automatic_write:true});
|
||||
const dispositions = await viewer.request('/stock/ocorrencias');
|
||||
assert.equal(dispositions.status,200); assert.equal(dispositions.page.component,'stock/dispositions'); assert.equal(dispositions.page.props.ready,true);
|
||||
for (const permission of ['prepare','review','execute']) assert.equal(dispositions.page.props.capabilities[permission],false);
|
||||
proof.push({view:'dispositions',status:dispositions.status,ready:true,admin_has_no_automatic_write:true});
|
||||
const reversals = await viewer.request('/stock/estornos');
|
||||
assert.equal(reversals.status,200); assert.equal(reversals.page.component,'stock/reversals'); assert.equal(reversals.page.props.ready,true);
|
||||
for (const permission of ['prepare','review','execute']) assert.equal(reversals.page.props.capabilities[permission],false);
|
||||
proof.push({view:'reversals',status:reversals.status,ready:true,admin_has_no_automatic_write:true});
|
||||
await fs.writeFile('docs/proofs/supply-navigation.json', JSON.stringify({checked_at:new Date().toISOString(),checks:proof},null,2));
|
||||
console.log('Eleven installed local supply/stock pages verified; no business commands or grants performed.');
|
||||
`;
|
||||
const run = new Function('fs','assert','return (async()=>{'+code+'})()');
|
||||
await run(fs,assert);
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
|
||||
$config = DB::connection('pgsql')->getConfig();
|
||||
unset($config['name']);
|
||||
if ($config['host'] !== '127.0.0.1') {
|
||||
throw new LogicException('Prova de fornecimento apenas em servidor local.');
|
||||
}
|
||||
$database = 'compras_receipts_test_'.bin2hex(random_bytes(6));
|
||||
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', $database)) {
|
||||
throw new LogicException('Nome de base descartável inválido.');
|
||||
}
|
||||
config(['database.connections.supply_proof_admin' => [...$config, 'database' => 'postgres', 'url' => null]]);
|
||||
$admin = DB::connection('supply_proof_admin');
|
||||
$admin->statement('CREATE DATABASE "'.$database.'"');
|
||||
try {
|
||||
$environment = [
|
||||
'APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_HOST' => $config['host'], 'DB_PORT' => (string) $config['port'],
|
||||
'DB_DATABASE' => $database, 'DB_USERNAME' => $config['username'], 'DB_PASSWORD' => $config['password'],
|
||||
];
|
||||
$process = new Process([PHP_BINARY, 'vendor/bin/pest', '--compact', 'tests/Feature/SupplyPlanningTest.php', 'tests/Feature/SupplyCoordinationTest.php', 'tests/Feature/SupplySuggestionsTest.php', 'tests/Feature/SupplyHistoryTest.php', 'tests/Feature/InventoryLotsTest.php', 'tests/Feature/InventoryCountsTest.php', 'tests/Feature/InventoryDispositionsTest.php', 'tests/Feature/InventoryLotPolicyTest.php', 'tests/Feature/InventoryReversalsTest.php'], dirname(__DIR__), $environment, timeout: 120);
|
||||
$process->run();
|
||||
echo $process->getOutput();
|
||||
if (! $process->isSuccessful()) {
|
||||
echo $process->getErrorOutput();
|
||||
throw new RuntimeException('Prova PostgreSQL não aprovada.');
|
||||
}
|
||||
$fixture = new Process([PHP_BINARY, 'tests/Concurrency/supply-worker.php', 'prepare'], dirname(__DIR__), $environment, timeout: 20);
|
||||
$fixture->mustRun();
|
||||
$ids = json_decode($fixture->getOutput(), true, 16, JSON_THROW_ON_ERROR);
|
||||
config(['database.connections.supply_proof_read' => [...$config, 'database' => $database, 'url' => null]]);
|
||||
$proofDb = DB::connection('supply_proof_read');
|
||||
$races = [];
|
||||
foreach (['same_reference', 'same_operation', 'single_conversion', 'conversion_retry'] as $scenario) {
|
||||
$conversion = in_array($scenario, ['single_conversion', 'conversion_retry'], true);
|
||||
if ($conversion) {
|
||||
$fixture = new Process([PHP_BINARY, 'tests/Concurrency/supply-worker.php', 'prepare_suggestion'], dirname(__DIR__), $environment, timeout: 20);
|
||||
$fixture->mustRun();
|
||||
$ids = json_decode($fixture->getOutput(), true, 16, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
$data = [...$ids, 'reference' => 'RACE-'.$scenario, 'operation_key' => (string) Str::uuid()];
|
||||
$beforeRequests = $proofDb->table('requisitions')->where('tenant_id', $ids['tenant'])->count();
|
||||
$first = new Process([PHP_BINARY, 'tests/Concurrency/supply-worker.php', $conversion ? 'hold_suggestion' : 'hold', json_encode($data, JSON_THROW_ON_ERROR)], dirname(__DIR__), $environment, timeout: 20);
|
||||
$first->start();
|
||||
$deadline = microtime(true) + 5;
|
||||
while (! str_contains($first->getOutput(), 'locked') && $first->isRunning() && microtime(true) < $deadline) {
|
||||
usleep(20000);
|
||||
}
|
||||
if (! str_contains($first->getOutput(), 'locked')) {
|
||||
throw new RuntimeException('Primeiro worker não adquiriu o lock.');
|
||||
}
|
||||
if (in_array($scenario, ['same_reference', 'single_conversion'], true)) {
|
||||
$data['operation_key'] = (string) Str::uuid();
|
||||
}
|
||||
$second = new Process([PHP_BINARY, 'tests/Concurrency/supply-worker.php', $conversion ? 'convert_suggestion' : 'write', json_encode($data, JSON_THROW_ON_ERROR)], dirname(__DIR__), $environment, timeout: 20);
|
||||
$second->start();
|
||||
$blocked = false;
|
||||
$deadline = microtime(true) + 5;
|
||||
while ($first->isRunning() && microtime(true) < $deadline) {
|
||||
$blocked = $proofDb->selectOne("select exists(select 1 from pg_stat_activity where datname = ? and wait_event_type = 'Lock') as blocked", [$database])->blocked;
|
||||
if ($blocked) {
|
||||
break;
|
||||
}
|
||||
usleep(20000);
|
||||
}
|
||||
$first->wait();
|
||||
$second->wait();
|
||||
$a = json_decode(trim(str_replace('locked', '', $first->getOutput())), true, 16, JSON_THROW_ON_ERROR);
|
||||
$b = json_decode(trim($second->getOutput()), true, 16, JSON_THROW_ON_ERROR);
|
||||
$expected = in_array($scenario, ['same_reference', 'single_conversion'], true) ? $b['result'] === 'rejected' : $b['result'] === 'saved' && $b['id'] === $a['id'];
|
||||
if (! $blocked || $a['result'] !== 'saved' || ! $expected) {
|
||||
throw new RuntimeException('Concorrência ou idempotência não demonstradas.');
|
||||
}
|
||||
if ($conversion && $proofDb->table('requisitions')->where('tenant_id', $ids['tenant'])->count() !== $beforeRequests + 1) {
|
||||
throw new RuntimeException('A conversão não criou exactamente uma requisição.');
|
||||
}
|
||||
$races[] = ['scenario' => $scenario, 'real_lock_contention' => true, 'result' => 'passed'];
|
||||
}
|
||||
foreach (['lot_double_issue', 'lot_identification_release', 'lot_double_transfer', 'lot_double_adjustment', 'double_supplier_return', 'lot_policy_stock_change', 'double_stock_reversal'] as $scenario) {
|
||||
$adjustment = $scenario === 'lot_double_adjustment';
|
||||
$return = $scenario === 'double_supplier_return';
|
||||
$policy = $scenario === 'lot_policy_stock_change';
|
||||
$reversal = $scenario === 'double_stock_reversal';
|
||||
$fixtureMode = $reversal ? 'prepare_reversal' : ($policy ? 'prepare_policy' : ($return ? 'prepare_return' : ($adjustment ? 'prepare_count' : 'prepare_lots')));
|
||||
$fixture = new Process([PHP_BINARY, 'tests/Concurrency/supply-worker.php', $fixtureMode], dirname(__DIR__), $environment, timeout: 20);
|
||||
$fixture->mustRun();
|
||||
$data = json_decode($fixture->getOutput(), true, 16, JSON_THROW_ON_ERROR);
|
||||
$data['operation_key'] = (string) Str::uuid();
|
||||
$data['selected'] = 0;
|
||||
$transfer = $scenario === 'lot_double_transfer';
|
||||
$issue = $scenario === 'lot_double_issue' || $transfer || $policy;
|
||||
$firstMode = $reversal ? 'hold_stock_reversal' : ($return ? 'hold_disposition' : ($adjustment ? 'hold_lot_adjust' : ($issue ? ($transfer ? 'hold_lot_transfer' : 'hold_lot_issue') : 'hold_lot_identify')));
|
||||
$first = new Process([PHP_BINARY, 'tests/Concurrency/supply-worker.php', $firstMode, json_encode($data, JSON_THROW_ON_ERROR)], dirname(__DIR__), $environment, timeout: 20);
|
||||
$first->start();
|
||||
$deadline = microtime(true) + 5;
|
||||
while (! str_contains($first->getOutput(), 'locked') && $first->isRunning() && microtime(true) < $deadline) {
|
||||
usleep(20000);
|
||||
}
|
||||
if (! str_contains($first->getOutput(), 'locked')) {
|
||||
throw new RuntimeException('Worker de lotes não adquiriu o lock.');
|
||||
}
|
||||
$data['operation_key'] = (string) Str::uuid();
|
||||
$data['selected'] = 1;
|
||||
$secondMode = $reversal ? 'stock_reversal' : ($policy ? 'lot_activate' : ($return ? 'disposition' : ($adjustment ? 'lot_adjust' : ($issue ? ($transfer ? 'lot_transfer' : 'lot_issue') : 'lot_release'))));
|
||||
$second = new Process([PHP_BINARY, 'tests/Concurrency/supply-worker.php', $secondMode, json_encode($data, JSON_THROW_ON_ERROR)], dirname(__DIR__), $environment, timeout: 20);
|
||||
$second->start();
|
||||
$blocked = false;
|
||||
$deadline = microtime(true) + 5;
|
||||
while ($first->isRunning() && microtime(true) < $deadline) {
|
||||
$blocked = $proofDb->selectOne("select exists(select 1 from pg_stat_activity where datname = ? and wait_event_type = 'Lock') as blocked", [$database])->blocked;
|
||||
if ($blocked) {
|
||||
break;
|
||||
}
|
||||
usleep(20000);
|
||||
}
|
||||
$first->wait();
|
||||
$second->wait();
|
||||
$a = json_decode(trim(str_replace('locked', '', $first->getOutput())), true, 16, JSON_THROW_ON_ERROR);
|
||||
$b = json_decode(trim($second->getOutput()), true, 16, JSON_THROW_ON_ERROR);
|
||||
if (! $blocked || $a['result'] !== 'saved' || $b['result'] !== 'rejected') {
|
||||
throw new RuntimeException('Corrida de lotes não preservou a conferência.');
|
||||
}
|
||||
if ($issue || $adjustment || $return || $reversal) {
|
||||
$book = $proofDb->table('stock_movements')->where('tenant_id', $data['tenant'])->sum('quantity_milli');
|
||||
$lots = $proofDb->table('inventory_lot_allocations')->where('tenant_id', $data['tenant'])->sum('quantity_milli');
|
||||
$expectedTotal = $reversal ? 10000 : ($return ? 13000 : ($adjustment ? 9000 : ($transfer ? 10000 : 3000)));
|
||||
if ((int) $book !== $expectedTotal || (int) $lots !== ($return || $reversal ? 0 : $expectedTotal)) {
|
||||
throw new RuntimeException('Saída concorrente alterou o saldo indevidamente.');
|
||||
}
|
||||
if ($transfer && ((int) $proofDb->table('stock_movements')->where('tenant_id', $data['tenant'])->where('warehouse_id', $data['warehouse'])->sum('quantity_milli') !== 3000
|
||||
|| (int) $proofDb->table('inventory_lot_allocations')->where('tenant_id', $data['tenant'])->where('warehouse_id', $data['destination'])->sum('quantity_milli') !== 7000)) {
|
||||
throw new RuntimeException('Transferência concorrente não conservou as duas pontas.');
|
||||
}
|
||||
}
|
||||
$races[] = ['scenario' => $scenario, 'real_lock_contention' => true, 'result' => 'passed'];
|
||||
}
|
||||
file_put_contents(dirname(__DIR__).'/docs/proofs/supply-coordination-pg.json', json_encode(['checked_at' => gmdate('c'), 'database' => 'fresh disposable PostgreSQL', 'tests' => ['SupplyPlanningTest', 'SupplyCoordinationTest', 'SupplySuggestionsTest', 'SupplyHistoryTest', 'InventoryLotsTest', 'InventoryCountsTest', 'InventoryDispositionsTest', 'InventoryLotPolicyTest', 'InventoryReversalsTest'], 'result' => 'passed', 'races' => $races, 'persistent_data_changed' => false, 'output' => trim($process->getOutput())], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo "Eleven PostgreSQL races passed with real lock contention.\n";
|
||||
} finally {
|
||||
DB::purge('supply_proof_read');
|
||||
$admin->select('select pg_terminate_backend(pid) from pg_stat_activity where datname = ? and pid <> pg_backend_pid()', [$database]);
|
||||
$admin->statement('DROP DATABASE "'.$database.'"');
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
|
||||
try {
|
||||
if (count($argv) !== 3 || ! is_file($argv[1].'/scripts/sgf.ps1')) {
|
||||
throw new RuntimeException('Uso: transfer-purchasing.php <instalação-SGF> <canal-de-versões>.');
|
||||
}
|
||||
$host = realpath($argv[1]);
|
||||
$publish = new Process([PHP_BINARY, 'scripts/publish-purchasing.php', $argv[2]], dirname(__DIR__), timeout: 180);
|
||||
$publish->mustRun();
|
||||
$release = json_decode($publish->getOutput(), true, 64, JSON_THROW_ON_ERROR);
|
||||
$archive = realpath($argv[2]).'/'.$release['archive'];
|
||||
$run = function (string $action) use ($host, $archive, $release): array {
|
||||
// Respect the host's pinned PHP/dependencies; no PATH or dependency replacement.
|
||||
$process = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $host.'/scripts/sgf.ps1', 'php', 'scripts/purchasing-release.php', $action, $host, $archive, $release['manifest_sha256']], $host, timeout: 60);
|
||||
$process->mustRun();
|
||||
|
||||
return json_decode($process->getOutput(), true, 64, JSON_THROW_ON_ERROR);
|
||||
};
|
||||
$plan = $run('check');
|
||||
$installed = $plan['available'] ? $run('install') : ['changed' => false, 'version' => $plan['installed_version']];
|
||||
echo json_encode(['release' => $release, 'plan' => $plan, 'installation' => $installed, 'database_changes' => false, 'permissions_changed' => false], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
|
||||
} catch (Throwable $error) {
|
||||
fwrite(STDERR, $error->getMessage()."\n");
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
const base = 'http://127.0.0.1:8005';
|
||||
const cookies = new Map();
|
||||
async function request(path, body) {
|
||||
const response = await fetch(base + path, {
|
||||
method: body ? 'POST' : 'GET',
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Cookie: [...cookies]
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('; '),
|
||||
...(body
|
||||
? {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(
|
||||
cookies.get('XSRF-TOKEN') ?? '',
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(body ? { body: new URLSearchParams(body) } : {}),
|
||||
});
|
||||
for (const cookie of response.headers.getSetCookie()) {
|
||||
const pair = cookie.split(';', 1)[0],
|
||||
index = pair.indexOf('=');
|
||||
cookies.set(pair.slice(0, index), pair.slice(index + 1));
|
||||
}
|
||||
const text = await response.text();
|
||||
const match = text.match(
|
||||
/<script data-page="app" type="application\/json">([\s\S]*?)<\/script>/,
|
||||
);
|
||||
return {
|
||||
status: response.status,
|
||||
page: match ? JSON.parse(match[1]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
const first = await request('/dashboard');
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(first.page.component, 'work-context');
|
||||
assert.equal(first.page.props.context.available.length, 4);
|
||||
const results = [];
|
||||
for (const [option, allowed, denied, expected] of [
|
||||
['procurement.internal', '/requisicoes', '/pessoas', 200],
|
||||
['people.internal', '/pessoas', '/requisicoes', 200],
|
||||
['procurement.sales', '/requisicoes', '/pessoas', 302],
|
||||
['people.third_party', '/pessoas', '/requisicoes', 302],
|
||||
]) {
|
||||
const selected = await request('/contexto', {
|
||||
area: option.split('.')[0],
|
||||
option,
|
||||
});
|
||||
assert.equal(selected.status, 302);
|
||||
const dashboard = await request('/dashboard');
|
||||
assert.equal(dashboard.page.props.context.active, option);
|
||||
const access = await request(allowed),
|
||||
blocked = await request(denied);
|
||||
assert.equal(access.status, expected);
|
||||
assert.equal(blocked.status, 403);
|
||||
results.push({ option, access: access.status, other_area: blocked.status });
|
||||
}
|
||||
await request('/contexto', {
|
||||
area: 'procurement',
|
||||
option: 'procurement.internal',
|
||||
});
|
||||
const proof = {
|
||||
checked_at: new Date().toISOString(),
|
||||
environment: base,
|
||||
evaluation_only: true,
|
||||
results,
|
||||
created_accounts: false,
|
||||
business_commands: false,
|
||||
visual_verified: false,
|
||||
};
|
||||
await fs.writeFile(
|
||||
new URL('../docs/proofs/work-context-http.json', import.meta.url),
|
||||
JSON.stringify(proof, null, 2) + '\n',
|
||||
);
|
||||
console.log(JSON.stringify(proof));
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$config = DB::connection('pgsql')->getConfig();
|
||||
unset($config['name']);
|
||||
if ($config['host'] !== '127.0.0.1') {
|
||||
throw new LogicException('Prova de contexto apenas em servidor local.');
|
||||
}
|
||||
$database = 'compras_receipts_test_'.bin2hex(random_bytes(6));
|
||||
config(['database.connections.context_proof_admin' => [...$config, 'database' => 'postgres', 'url' => null]]);
|
||||
$admin = DB::connection('context_proof_admin');
|
||||
$admin->statement('CREATE DATABASE "'.$database.'"');
|
||||
$workflow = in_array('--workflow', $argv ?? [], true);
|
||||
$warehouse = in_array('--warehouse', $argv ?? [], true);
|
||||
try {
|
||||
$environment = ['APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_HOST' => $config['host'], 'DB_PORT' => (string) $config['port'],
|
||||
'DB_DATABASE' => $database, 'DB_USERNAME' => $config['username'], 'DB_PASSWORD' => $config['password']];
|
||||
$tests = $workflow ? ['tests/Feature/InternalRequisitionCandidateTest.php', 'tests/Feature/WorkContextTest.php'] : ['tests/Feature/WorkContextTest.php'];
|
||||
if ($warehouse) {
|
||||
$tests = ['tests/Feature/WarehouseRequestsTest.php', 'tests/Feature/WarehouseAvailabilityTest.php', 'tests/Feature/WarehouseFulfilmentTest.php'];
|
||||
}
|
||||
$process = new Process([PHP_BINARY, 'vendor/bin/pest', '--compact', ...$tests], dirname(__DIR__), $environment, timeout: 120);
|
||||
$process->run();
|
||||
echo $process->getOutput();
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new RuntimeException('Prova PostgreSQL de contexto não aprovada.');
|
||||
}
|
||||
$races = [];
|
||||
if ($warehouse) {
|
||||
config(['database.connections.warehouse_proof_read' => [...$config, 'database' => $database, 'url' => null]]);
|
||||
$reader = DB::connection('warehouse_proof_read');
|
||||
foreach (['same_operation', 'different_operations'] as $scenario) {
|
||||
$fixture = new Process([PHP_BINARY, 'tests/Concurrency/warehouse-worker.php', 'prepare'], dirname(__DIR__), $environment, timeout: 20);
|
||||
$fixture->mustRun();
|
||||
$data = json_decode($fixture->getOutput(), true, 32, JSON_THROW_ON_ERROR);
|
||||
$first = new Process([PHP_BINARY, 'tests/Concurrency/warehouse-worker.php', 'hold', json_encode($data, JSON_THROW_ON_ERROR)], dirname(__DIR__), $environment, timeout: 20);
|
||||
$first->start();
|
||||
$deadline = microtime(true) + 5;
|
||||
while ($first->isRunning() && ! str_contains($first->getOutput(), 'locked') && microtime(true) < $deadline) {
|
||||
usleep(20000);
|
||||
}
|
||||
if (! str_contains($first->getOutput(), 'locked')) {
|
||||
throw new RuntimeException('Primeiro executor sem bloqueio demonstrado.');
|
||||
}
|
||||
if ($scenario === 'different_operations') {
|
||||
$data['input']['operation_key'] = (string) Str::uuid();
|
||||
}
|
||||
$second = new Process([PHP_BINARY, 'tests/Concurrency/warehouse-worker.php', 'run', json_encode($data, JSON_THROW_ON_ERROR)], dirname(__DIR__), $environment, timeout: 20);
|
||||
$second->start();
|
||||
$blocked = false;
|
||||
$deadline = microtime(true) + 5;
|
||||
while ($first->isRunning() && microtime(true) < $deadline) {
|
||||
$blocked = (bool) $reader->selectOne("select exists(select 1 from pg_stat_activity where datname=? and wait_event_type='Lock') as blocked", [$database])->blocked;
|
||||
if ($blocked) {
|
||||
break;
|
||||
} usleep(20000);
|
||||
}
|
||||
$first->wait();
|
||||
$second->wait();
|
||||
$a = json_decode(trim(str_replace('locked', '', $first->getOutput())), true, 16, JSON_THROW_ON_ERROR);
|
||||
$b = json_decode(trim($second->getOutput()), true, 16, JSON_THROW_ON_ERROR);
|
||||
if (! $blocked || $a['result'] !== 'saved' || ($scenario === 'same_operation' ? ($b['result'] !== 'saved' || $a['event'] !== $b['event']) : $b['result'] !== 'rejected')) {
|
||||
throw new RuntimeException('Disputa não preservou entrega única.');
|
||||
}
|
||||
if ((int) $reader->table('warehouse_request_events')->where('warehouse_request_id', $data['request'])->where('action', 'deliver')->count() !== 1
|
||||
|| (int) $reader->table('stock_movements')->where('tenant_id', $data['tenant'])->where('stock_item_id', $data['stock'])->where('warehouse_id', $data['warehouse'])->sum('quantity_milli') !== 6000) {
|
||||
throw new RuntimeException('Movimento ou entrega duplicados.');
|
||||
}
|
||||
$races[] = ['scenario' => $scenario, 'real_lock_contention' => true, 'result' => 'passed'];
|
||||
}
|
||||
DB::purge('warehouse_proof_read');
|
||||
}
|
||||
file_put_contents(dirname(__DIR__).'/docs/proofs/'.($warehouse ? 'warehouse-drafts-pg' : ($workflow ? 'workflow-notices-pg' : 'work-context-pg')).'.json', json_encode(['checked_at' => gmdate('c'), 'database' => 'disposable PostgreSQL', 'result' => trim($process->getOutput()), 'races' => $races, 'persistent_data_changed' => false, 'database_removed_in_finally' => true], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
} finally {
|
||||
$admin->select('select pg_terminate_backend(pid) from pg_stat_activity where datname = ? and pid <> pg_backend_pid()', [$database]);
|
||||
$admin->statement('DROP DATABASE "'.$database.'"');
|
||||
}
|
||||
Reference in New Issue
Block a user