Files
Investidor-V2/tools/portal-investor-path-qa.php
nala 6ddafc95ba
tests / ci (push) Canceled after 0s
oportunidades 4
2026-10-10 07:03:59 +02:00

200 lines
13 KiB
PHP

<?php
// Rehearse only the new extension against the existing verified restore. Never connect to the active target.
use App\Models\User;
use App\Services\InvestorPath;
use App\Services\InvestorPathDefinition;
use App\Services\OpportunityResourceInstallation;
use App\Services\SimulationFormDrafts;
use App\Services\TerritoryGeometryImport;
use App\Services\VirtualDesk;
use App\Services\VirtualDeskInstallation;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
$root = dirname(__DIR__);
require_once $root.'/tools/portal-qa-environment.php';
$directory = $root.'/storage/app/private/investmoz-v2/portal-audit';
$backup = json_decode(file_get_contents($directory.'/../opportunity-resources-backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
$target = $backup['restored_target'];
if (! ($backup['restore_verified'] ?? false) || ! preg_match('/^desk_restore_\d{8}_\d{6}_test$/D', $target['database']) || (int) $target['port'] !== 55441) {
throw new RuntimeException('Verified restored QA required.');
}
$target = portalQaEnvironment();
require $root.'/vendor/autoload.php';
$app = require $root.'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$identity = portalQaAssertTarget($target);
if (($argv[1] ?? '') === 'evidence') {
DB::beginTransaction();
DB::statement('SET TRANSACTION READ ONLY');
$data = ['captured_at' => gmdate('c'), 'target' => $target, 'code_hash' => app(VirtualDeskInstallation::class)->fingerprint(),
'geometry_code_hash' => app(TerritoryGeometryImport::class)->fingerprint(),
'resource_code_hash' => app(OpportunityResourceInstallation::class)->fingerprint(),
'qa_path_journeys' => DB::table('investor_journeys')->whereNotNull('path_definition_id')->count(),
'qa_path_steps' => DB::table('journey_steps')->where('source_system', 'investor-path')->count(),
'qa_private_geometries' => DB::table('geo_geometries')->where('source_system', 'ine-cod-ab')->where('data_classification', 'internal')->count(),
'active_writes' => 0];
$resourceProof = $directory.'/resources-real-http-qa.json';
if (is_file($resourceProof)) {
$resource = json_decode(file_get_contents($resourceProof), true, 512, JSON_THROW_ON_ERROR);
if ($resource['target'] !== $target || ! $resource['api_persistence_passed']) {
throw new RuntimeException('Resource evidence from another target refused.');
}
$ids = [$resource['original_image_document_id'], ...array_column($resource['derivatives'], 'document_id'), DB::table('opportunity_documents')->where('id', $resource['document_resource_id'])->value('document_id')];
$documents = DB::table('documents')->whereIn('id', $ids)->get(['id', 'sha256', 'malware_scanned_sha256', 'status', 'storage_encryption', 'data_classification', 'malware_scan_status', 'malware_engine', 'malware_engine_version', 'malware_signatures_at', 'malware_scanned_at', 'malware_scan_attempts']);
foreach ($documents as $document) {
if ($document->malware_scan_status !== 'clean' || $document->malware_engine !== 'ClamAV' || trim($document->sha256) !== $document->malware_scanned_sha256 || $document->storage_encryption !== 'laravel' || $document->data_classification !== 'restricted') {
throw new RuntimeException('Real independent clean/encrypted/restricted evidence absent.');
}
}
if (count($documents) !== 5) {
throw new RuntimeException('Five original/derivative documents expected in this QA operation.');
}
$persistence = ['captured_at' => gmdate('c'), 'target' => $target, 'documents' => $documents, 'scans' => DB::table('document_malware_scans')->whereIn('document_id', $ids)->get(), 'passed' => true, 'active_writes' => 0, 'interface_observed' => false];
file_put_contents($directory.'/document-real-persistence-qa.json', json_encode($persistence, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
}
DB::rollBack();
file_put_contents($directory.'/investor-path-qa-runtime.json', json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($data).PHP_EOL;
exit;
}
if (($argv[1] ?? '') === 'fixtures') {
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
$actor = User::firstOrCreate(['email' => 'investor-path-qa@example.invalid'], [
'name' => 'Investidor sintético A/B QA', 'password' => Hash::make('Investor-Path-QA-2026!'), 'email_verified_at' => now(),
]);
if (! $actor->hasVerifiedEmail()) {
$actor->forceFill(['email_verified_at' => now()])->save();
}
$outsider = User::firstOrCreate(['email' => 'investor-path-outsider-qa@example.invalid'], [
'name' => 'Outra conta sintética QA', 'password' => Hash::make('Outsider-Path-QA-2026!'),
]);
if (! $outsider->hasVerifiedEmail()) {
$outsider->forceFill(['email_verified_at' => now()])->save();
}
$path = app(InvestorPath::class);
$desk = app(VirtualDesk::class);
$drafts = app(SimulationFormDrafts::class);
$fixtures = [];
foreach (['A', 'B'] as $index => $scenario) {
$id = $path->create($actor, ['title' => 'Cenário '.$scenario.' sintético QA — '.($index ? 'terra e financiamento' : 'sem terra nem financiamento externo'),
'client_key' => '61000919-0000-4000-8000-00000000000'.($index + 1), 'reuse_consent' => true]);
$view = $path->view($actor, $id);
if ($view['facts']['land_needed'] === 'unknown' && $view['facts']['external_funding'] === 'unknown') {
$path->facts($actor, $id, ['version' => $view['case']['version'], 'land_needed' => $index ? 'yes' : 'no', 'external_funding' => $index ? 'yes' : 'no',
'viability' => 'proceed', 'reason' => 'Fixture sintética para demonstração; decisão declarada de preparação, sem aprovação externa.']);
}
$request = Request::create('/', 'POST', ['context' => $id]);
$request->setUserResolver(fn () => $actor);
$case = $desk->case($actor, $id);
if (! $case->simulation_id) {
$snapshot = ['version' => 1, 'data' => ['budgetPeriod' => 'Mensal',
'loan' => ['principal' => '100000', 'rate' => '0', 'months' => '24', 'rateType' => 'nominal', 'periodMonths' => '1', 'gracePeriods' => '0', 'upfrontFee' => '0', 'recurringFee' => '0'],
'breakEven' => ['fixed' => '1000', 'price' => '100', 'variable' => '50'], 'funding' => ['total' => '100000', 'secured' => $index ? '30000' : '100000'],
'incomes' => [['label' => 'Receita sintética QA', 'amount' => '100']], 'expenses' => [['label' => 'Custo sintético QA', 'amount' => '50']]]];
$created = $drafts->create(['personal_snapshot' => [], 'economic_snapshot' => ['purpose' => 'simulate_only',
'project_title' => 'Ideia '.$scenario.' sintética QA', 'project_summary' => 'Dados fictícios para revisão do percurso integrado.', 'currency' => 'MZN',
'financial_tools_snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR)]], $request, 'qa-fixture-path-'.$scenario.'-2026');
$path->returnTool($request, $id, ['version' => $path->view($actor, $id)['case']['version'], 'draft_id' => $created['draft']->id, 'draft_version' => $created['draft']->version_no]);
}
$fixtures[] = ['scenario' => $scenario, 'id' => $id, 'url' => '/investimentos/'.$id, 'steps' => count($path->view($actor, $id)['steps']), 'ui_demonstrated' => false];
}
$downRefused = false;
try {
(require $root.'/database/migrations/2026_10_09_190000_prepare_investor_path.php')->down();
} catch (RuntimeException $exception) {
$downRefused = str_contains($exception->getMessage(), 'Preserve investor path history');
}
if (! $downRefused) {
throw new RuntimeException('Used-data rollback guard was not demonstrated.');
}
$proof = ['target' => $target, 'fixtures' => $fixtures, 'account' => $actor->email, 'outsider_account' => $outsider->email, 'fixture_setup_only' => true,
'used_down_refused' => true, 'active_writes' => 0, 'visual_verified' => false];
file_put_contents($directory.'/investor-path-fixtures.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($proof).PHP_EOL;
exit;
}
$name = '2026_10_09_190000_prepare_investor_path';
$quote = fn (string $name): string => '"'.str_replace('"', '""', $name).'"';
$columns = [];
foreach (DB::select("SELECT table_name,column_name FROM information_schema.columns WHERE table_schema='public' ORDER BY table_name,ordinal_position") as $row) {
$columns[$row->table_name][] = $row->column_name;
}
$snapshot = function () use ($columns, $quote): array {
$hashes = [];
foreach ($columns as $table => $fields) {
if (! Schema::hasTable($table) || ! DB::selectOne("SELECT EXISTS(SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename=?) AS present", [$table])->present) {
continue;
}
$projection = implode(',', array_map($quote, $fields));
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM (SELECT '.$projection.' FROM '.$quote($table).') t ORDER BY row_to_json(t)::text');
$hashes[$table] = hash('sha256', implode("\n", array_column($rows, 'row')));
}
return $hashes;
};
DB::beginTransaction();
try {
DB::select('select pg_advisory_xact_lock(261009, 1919)');
$before = $snapshot();
if (Schema::hasColumn('investor_journeys', 'path_definition_id')) {
// Idempotent repeat: validate the published snapshot, do not repeat installation or rollback used data.
$record = DB::table('questionnaire_versions')->where('id', config('investor-path.definition_id'))->firstOrFail();
InvestorPathDefinition::validate(json_decode($record->schema_snapshot, true, 512, JSON_THROW_ON_ERROR));
$unused = ! DB::table('investor_journeys')->whereNotNull('path_definition_id')->exists();
if ($unused) {
$migration = require $root.'/database/migrations/'.$name.'.php';
$migration->down();
$migration->up();
}
DB::rollBack();
if ($snapshot() !== $before) {
throw new RuntimeException('QA replay changed records.');
}
$proofFile = $directory.'/investor-path-compatibility.json';
$proof = json_decode(file_get_contents($proofFile), true, 512, JSON_THROW_ON_ERROR);
$proof['code_hash'] = app(VirtualDeskInstallation::class)->fingerprint();
$proof['rechecked_at'] = gmdate('c');
$proof['replay_preserved'] = true;
$proof['final_unused_down_rechecked'] = $unused;
file_put_contents($proofFile, json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode(['target' => $target, 'already_installed_qa' => true, 'installation_repeated' => false, 'active_writes' => 0]).PHP_EOL;
exit;
}
$migration = require $root.'/database/migrations/'.$name.'.php';
$migration->up();
$migration->down();
// Immutable unused definition retained by down; remove the entire rehearsal through transaction rollback.
DB::rollBack();
$after = $snapshot();
if ($before !== $after || Schema::hasColumn('investor_journeys', 'path_definition_id')) {
throw new RuntimeException('Rehearsal rollback changed original data/schema.');
}
DB::transaction(function () use ($migration, $name): void {
$migration->up();
DB::table('migrations')->insert(['migration' => $name, 'batch' => (DB::table('migrations')->max('batch') ?? 0) + 1]);
});
$after = $snapshot();
$exceptions = ['questionnaire_versions', 'migrations'];
$changed = array_keys(array_filter($before, fn ($hash, $table) => ! in_array($table, $exceptions, true) && $hash !== ($after[$table] ?? null), ARRAY_FILTER_USE_BOTH));
if ($changed) {
throw new RuntimeException('Original records changed during installation: '.implode(',', $changed));
}
$proof = ['target' => $target, 'code_hash' => app(VirtualDeskInstallation::class)->fingerprint(), 'captured_at' => gmdate('c'),
'backup_dump_sha256' => $backup['sha256'], 'original_tables_compared' => count($before), 'changed_original_tables' => $changed,
'unused_down_and_transaction_restore_verified' => true, 'qa_installed' => true, 'migration' => $name,
'added_columns' => 2, 'definition_rows_added' => 1, 'existing_journeys_converted' => 0, 'new_journeys' => 0, 'new_steps' => 0,
'active_writes' => 0, 'integrated_ui_ab_passed' => false, 'activation_ready' => false];
file_put_contents($directory.'/investor-path-compatibility.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($proof).PHP_EOL;
} catch (Throwable $exception) {
if (DB::transactionLevel()) {
DB::rollBack();
}
throw $exception;
}