193 lines
13 KiB
PHP
193 lines
13 KiB
PHP
<?php
|
|
|
|
use App\Http\Middleware\EnsurePortalProfiles;
|
|
use App\Models\User;
|
|
use App\Services\OpportunityResourceInstallation;
|
|
use App\Services\PortalProfileAccess;
|
|
use App\Services\PortalSuperusers;
|
|
use Illuminate\Contracts\Console\Kernel;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Session\Middleware\StartSession;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Illuminate\Support\Facades\Vite;
|
|
use Inertia\Inertia;
|
|
use Laravel\Fortify\Features;
|
|
use Laravel\Fortify\Fortify;
|
|
|
|
$root = dirname(__DIR__);
|
|
require_once $root.'/tools/portal-qa-environment.php';
|
|
$dir = $root.'/storage/app/private/investmoz-v2';
|
|
$backup = json_decode(file_get_contents($dir.'/opportunity-resources-backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
|
|
$target = $backup['restored_target'];
|
|
if (! $backup['restore_verified'] || ! preg_match('/^desk_restore_\d{8}_\d{6}_test$/', $target['database']) || (int) $target['port'] !== 55441) {
|
|
throw new RuntimeException('QA target required');
|
|
}
|
|
$target = portalQaEnvironment('file');
|
|
require $root.'/vendor/autoload.php';
|
|
$app = require $root.'/bootstrap/app.php';
|
|
$app->make(Kernel::class)->bootstrap();
|
|
config(['investor-path.enabled' => true]);
|
|
config(['geometry-import.enabled' => true]);
|
|
config(['portal.interface_language' => 'pt-MZ']);
|
|
$identity = portalQaAssertTarget($target);
|
|
if (PHP_SAPI !== 'cli' && is_file($dir.'/portal-audit/qa-recovery-maintenance.json')) {
|
|
http_response_code(503);
|
|
header('Retry-After: 60');
|
|
header('Cache-Control: no-store');
|
|
header('X-Investmoz-QA-Target: '.$identity->database.':'.$identity->port);
|
|
echo 'QA temporariamente em recuperação técnica. Nenhuma alteração no ambiente ativo.';
|
|
|
|
return;
|
|
}
|
|
config(['portal.qa_synthetic_accounts_only' => true]);
|
|
require_once $root.'/tools/portal-qa-account-selection.php';
|
|
// Only expose the non-secret identity; passwords, TOTP secrets and recovery codes remain private.
|
|
Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [
|
|
'canResetPassword' => Features::enabled(Features::resetPasswords()),
|
|
'status' => $request->session()->get('status'),
|
|
'qaAccount' => ['id' => 18, 'email' => User::findOrFail(18)->email],
|
|
]));
|
|
Fortify::twoFactorChallengeView(function (Request $request) {
|
|
$actor = User::findOrFail($request->session()->get('login.id'));
|
|
|
|
return Inertia::render('auth/two-factor-challenge', ['qaAccount' => ['id' => $actor->id, 'email' => $actor->email]]);
|
|
});
|
|
config(['portal.managers' => ['opportunity-manager@example.invalid', 'geo-manager-qa@example.invalid'], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
|
|
$sessions = $dir.'/qa-opportunity-resources-sessions';
|
|
if (! is_dir($sessions)) {
|
|
mkdir($sessions, 0700, true);
|
|
}
|
|
config(['session.files' => $sessions]);
|
|
config(['portal.opportunity_dashboard_access_enabled' => true, 'portal.qa_build_directory' => 'build-qa-opportunities']);
|
|
config(['portal.opportunity_specialized_drafts_enabled' => true]);
|
|
config(['portal.opportunity_modular_profiles_qa_enabled' => true]);
|
|
config(['portal.organization_country_enabled' => true]);
|
|
// Explicit synthetic acceptance subtype, never an approval/classification of a real opportunity.
|
|
config(['portal.opportunity_other_qa_subtypes.qa-test-subtype' => ['approved' => true, 'version' => 'synthetic-acceptance-v1',
|
|
'decision_reference' => 'Synthetic QA acceptance fixture only; real subtypes require functional approval',
|
|
'gates' => ['technical_review', 'legal_territorial_review']]]);
|
|
config(['portal.profile_access_enabled' => true]);
|
|
config(['portal.legacy_content_qa_enabled' => true]);
|
|
// QA-only bootstrap ceremony: fixed synthetic identities, normal authenticated MFA.
|
|
Route::middleware(['web', 'auth', 'verified', EnsurePortalProfiles::class])->post('/qa-auditoria/perfis/inicializar', function (Request $request) use ($dir) {
|
|
$actors = json_decode(file_get_contents($dir.'/portal-audit/profiles-v1-actors-private.json'), true, 512, JSON_THROW_ON_ERROR);
|
|
abort_unless($request->user()->id === $actors['bootstrap']['id'], 403);
|
|
config(['portal_profiles.bootstrap_authorized' => true]);
|
|
try {
|
|
app(PortalSuperusers::class)->initialize(User::findOrFail($actors['super_first']['id']), User::findOrFail($actors['super_second']['id']), $request->user());
|
|
} finally {
|
|
config(['portal_profiles.bootstrap_authorized' => false]);
|
|
}
|
|
|
|
return response()->json(['initialized' => true, 'holders' => [$actors['super_first']['id'], $actors['super_second']['id']]]);
|
|
});
|
|
// Read-only report derived from the sole audit matrix, accessible only on QA.
|
|
function authorizeQaAuditReader(Request $request): void
|
|
{
|
|
$access = app(PortalProfileAccess::class);
|
|
$allowed = $request->user()->can('manage-portal') || $access->has($request->user(), 'auditor') || ($access->superuser($request->user()) && $access->mfa($request->user()));
|
|
if (! $allowed && ! $request->expectsJson()) {
|
|
if ($request->headers->has('X-Inertia')) {
|
|
// Render the refused destination as a full page instead of trapping login in an error modal.
|
|
abort(Inertia::location($request->url()));
|
|
}
|
|
// Refuse the administrative report without discarding an authenticated investor session.
|
|
// Keep a usable destination when Fortify returns to a previously requested report.
|
|
abort(response('<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Acesso reservado — InvestMoz QA</title></head><body style="font:16px/1.6 system-ui;max-width:44rem;margin:auto;padding:24px"><main><h1>Relatório reservado à gestão e auditoria</h1><p>A sua sessão está autenticada. Este perfil não tem autorização para consultar o relatório administrativo.</p><p><a href="/dashboard">Abrir o painel</a></p><p><a href="/investimentos">Continuar os meus investimentos</a></p></main></body></html>', 403)->header('Cache-Control', 'private, no-store'));
|
|
}
|
|
abort_unless($allowed, 403);
|
|
}
|
|
Route::middleware(['web', 'auth', 'verified'])->get('/qa-auditoria/pedidos-abertos', function (Request $request) use ($dir) {
|
|
authorizeQaAuditReader($request);
|
|
|
|
return response()->file($dir.'/portal-audit/pedidos-abertos.html', ['Cache-Control' => 'private, no-store']);
|
|
});
|
|
foreach (['relatorio-executivo', 'backlog', 'perfis-v1-reconciliacao', 'roteiro-manual-perfis-v1', 'legado-piloto-qa'] as $reportName) {
|
|
Route::middleware(['web', 'auth', 'verified'])->get('/qa-auditoria/'.$reportName, function (Request $request) use ($root, $reportName) {
|
|
authorizeQaAuditReader($request);
|
|
$content = file_get_contents($root.'/docs/investmoz/v2/auditoria-portal/'.$reportName.'.md');
|
|
|
|
return response('<!doctype html><html lang="pt"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>InvestMoz — '.$reportName.'</title><style>body{font:16px/1.55 system-ui;max-width:1100px;margin:auto;padding:24px}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit}</style><pre>'.htmlspecialchars($content, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'</pre></html>')->header('Cache-Control', 'private, no-store');
|
|
});
|
|
}
|
|
Vite::useHotFile($root.'/storage/framework/qa-opportunity-resources-hot-absent');
|
|
Vite::useBuildDirectory('build-qa-opportunities');
|
|
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === 'prepare') {
|
|
$migration = require $root.'/database/migrations/'.OpportunityResourceInstallation::MIGRATION.'.php';
|
|
$migration->up();
|
|
$oldColumns = array_keys((array) DB::selectOne('SELECT * FROM opportunities LIMIT 1'));
|
|
$oldColumns = array_diff($oldColumns, []);
|
|
$projection = implode(',', array_map(fn ($column) => '"'.$column.'"', $oldColumns));
|
|
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM (SELECT '.$projection.' FROM opportunities) t ORDER BY row_to_json(t)::text');
|
|
if (hash('sha256', implode("\n", array_column($rows, 'row'))) !== $backup['table_hashes']['"public"."opportunities"']) {
|
|
throw new RuntimeException('Existing opportunity values changed');
|
|
}
|
|
$migration->down();
|
|
foreach ($backup['table_hashes'] as $table => $hash) {
|
|
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM '.$table.' t ORDER BY row_to_json(t)::text');
|
|
if (hash('sha256', implode("\n", array_column($rows, 'row'))) !== $hash) {
|
|
throw new RuntimeException('Restore/rollback comparison failed: '.$table);
|
|
}
|
|
}
|
|
Artisan::call('migrate', ['--path' => 'database/migrations/'.OpportunityResourceInstallation::MIGRATION.'.php', '--force' => true]);
|
|
$user = User::updateOrCreate(['email' => 'opportunity-manager@example.invalid'], ['name' => 'QA opportunity manager', 'password' => Hash::make('Opportunity-QA-2026!')]);
|
|
$user->forceFill(['email_verified_at' => now()])->save();
|
|
$proof = ['target' => $target, 'code_hash' => app(OpportunityResourceInstallation::class)->fingerprint(), 'compatibility_verified' => true, 'reversal_verified' => true, 'tables_compared' => count($backup['table_hashes']), 'active_writes' => 0];
|
|
file_put_contents($dir.'/opportunity-resources-compatibility.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
|
echo json_encode($proof).PHP_EOL;
|
|
exit;
|
|
}
|
|
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === 'verify') {
|
|
$columns = array_diff(array_keys((array) DB::selectOne('SELECT * FROM opportunities LIMIT 1')), []);
|
|
$projection = implode(',', array_map(fn ($column) => '"'.$column.'"', $columns));
|
|
$checks = [];
|
|
foreach (['opportunities', 'opportunity_projects', 'opportunity_clusters', 'opportunity_locations', 'geo_locations', 'documents', 'opportunity_favourites', 'interest_requests', 'interest_submission_receipts', 'investment_messages', 'tasks', 'project_locations', 'cluster_projects', 'audit_events'] as $table) {
|
|
$key = '"public"."'.$table.'"';
|
|
$query = $table === 'opportunities' ? '(SELECT '.$projection.' FROM opportunities WHERE management_creation_key IS NULL)' : ($table === 'audit_events' ? "(SELECT * FROM audit_events WHERE source_system IS DISTINCT FROM 'opportunity-resources' AND source_system IS DISTINCT FROM 'opportunity-management')" : $key);
|
|
if ($table === 'audit_events' && isset($backup['audit_event_ids'])) {
|
|
// Compare every original event by identity; new QA actions must not hide or replace history.
|
|
$originalIds = array_map(fn ($id) => DB::connection()->getPdo()->quote($id), $backup['audit_event_ids']);
|
|
$query = '(SELECT * FROM audit_events WHERE id IN ('.($originalIds ? implode(',', $originalIds) : 'NULL').'))';
|
|
}
|
|
if ($table === 'documents') {
|
|
$oldDocumentColumns = array_diff(array_keys((array) DB::selectOne('SELECT * FROM documents LIMIT 1')), ['storage_encryption']);
|
|
$documentProjection = implode(',', array_map(fn ($column) => '"'.$column.'"', $oldDocumentColumns));
|
|
$query = '(SELECT '.$documentProjection." FROM documents WHERE source_system NOT IN ('opportunity-resources','opportunity-image-derivatives'))";
|
|
}
|
|
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM '.$query.' t ORDER BY row_to_json(t)::text');
|
|
$checks[$table] = hash('sha256', implode("\n", array_column($rows, 'row'))) === $backup['table_hashes'][$key];
|
|
if (! $checks[$table]) {
|
|
throw new RuntimeException('Baseline data changed: '.$table);
|
|
}
|
|
}
|
|
$browser = json_decode(file_get_contents($dir.'/opportunity-resources-browser.json'), true, 512, JSON_THROW_ON_ERROR);
|
|
$draft = DB::table('opportunities')->where('id', $browser['id'])->first();
|
|
if ($draft->status !== 'draft' || $draft->deleted_at !== null) {
|
|
throw new RuntimeException('Browser cycle not restored');
|
|
}
|
|
$proof = ['target' => $target, 'code_hash' => app(OpportunityResourceInstallation::class)->fingerprint(), 'preserved' => $checks, 'browser_draft_id' => $draft->id, 'browser_draft_restored' => true, 'origin_classifications' => DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->count(), 'active_writes' => 0];
|
|
file_put_contents($dir.'/opportunity-resources-preservation.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
|
echo json_encode($proof).PHP_EOL;
|
|
exit;
|
|
}
|
|
$path = rawurldecode(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
|
|
$asset = realpath($root.'/public'.$path);
|
|
$public = realpath($root.'/public');
|
|
if ($path !== '/' && $asset && $public && str_starts_with($asset, $public.DIRECTORY_SEPARATOR) && is_file($asset)) {
|
|
return false;
|
|
}
|
|
header('X-Investmoz-QA-Target: '.$identity->database.':'.$identity->port);
|
|
header('X-Investmoz-QA-Key-Fingerprint: '.hash('sha256', app('encrypter')->getKey()));
|
|
header('X-Investmoz-QA-Config-Cache: bypassed');
|
|
require_once $root.'/tools/portal-qa-auth-trace.php';
|
|
$httpKernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
|
|
$httpKernel->prependMiddleware(PortalQaAuthTrace::class);
|
|
$httpKernel->appendMiddlewareToGroup('web', PortalQaSessionTrace::class);
|
|
$httpKernel->addToMiddlewarePriorityAfter(StartSession::class, PortalQaSessionTrace::class);
|
|
$httpKernel->appendMiddlewareToGroup('web', PortalQaAccountSelection::class);
|
|
$httpKernel->addToMiddlewarePriorityAfter(PortalQaSessionTrace::class, PortalQaAccountSelection::class);
|
|
$app->handleRequest(Request::capture());
|