Files
Investidor-V2/tools/portal-workflow-activation-verify.php
T
nala 287e991486
tests / ci (push) Waiting to run
oportunidades 7
2026-10-13 11:40:29 +02:00

126 lines
16 KiB
PHP

<?php
// Destination and physically restored backup clone READ ONLY. No providers or write SQL.
ini_set('zend.exception_ignore_args', '1');
set_exception_handler(static function (Throwable $e): void {
fwrite(STDERR, 'Verification stopped: '.$e::class.' at '.basename($e->getFile()).':'.$e->getLine().PHP_EOL);
exit(1);
});
$phase=$argv[1]??'before';
if (!in_array($phase,['before','resources','installed','bootstrap'],true)) throw new RuntimeException('Unknown verification phase');
$root=dirname(__DIR__);$private=$root.'/storage/app/private/investmoz-v2/portal-audit';
require $root.'/vendor/autoload.php';
$revision='41caea0f900fe0705e84e065a65f9534ff3a585ee34098f860666f522875a3a5';
$candidate=$root.'/outputs/investmoz-workflow-consolidated-20261011';
$manifest=json_decode(file_get_contents($candidate.'/manifesto.json'),true,512,JSON_THROW_ON_ERROR);
$backup=json_decode(file_get_contents($private.'/workflow-execution-target-backup.json'),true,512,JSON_THROW_ON_ERROR);
$stage=json_decode(file_get_contents($private.'/workflow-activation-stage.json'),true,512,JSON_THROW_ON_ERROR);
throw_unless($manifest['revision']===$revision && $stage['revision']===$revision && $backup['candidate_revision']===$revision,RuntimeException::class,'Revision differs');
throw_unless(hash_file('sha256',$candidate.'/investmoz-workflow-41caea0f900f.zip')==='1d6dece89817459f012909f309f688e85e40752e845469855b2dce101879c102'
&& hash_file('sha256',$candidate.'/manifesto.json')==='092ea57b5eeecef8ae1c44f4c1a8dd6c95d63af5e4448524c46a9e251b1d9b63',RuntimeException::class,'Package differs');
throw_unless($backup['restore_verified'] && $backup['files_verified'] && $backup['file_count']===867
&& time()-strtotime($backup['verified_at'])<86400 && strtotime($backup['verified_at'])<=time(),RuntimeException::class,'Recovery proof expired');
throw_unless(hash_file('sha256',$backup['dump_path'])===$backup['dump_sha256'],RuntimeException::class,'Recovery dump differs');
foreach($manifest['payload'] as $name=>$hash) throw_unless(is_file($root.'/'.$name) && hash_file('sha256',$root.'/'.$name)===$hash,RuntimeException::class,'Deployed payload differs');
foreach($manifest['unchanged_dependencies'] as $name=>$hash) throw_unless(hash_file('sha256',$root.'/'.$name)===$hash,RuntimeException::class,'Preserved dependency differs');
throw_unless(hash_file('sha256',$root.'/.env')===$stage['unchanged_env_sha256'],RuntimeException::class,'Environment/cipher configuration differs');
$app=require $root.'/bootstrap/app.php';
(new Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables)->bootstrap($app);
(new Illuminate\Foundation\Bootstrap\LoadConfiguration)->bootstrap($app);
$c=config('database.connections.'.config('database.default'));
throw_unless($c['driver']==='pgsql' && empty($c['url']) && in_array($c['host'],['127.0.0.1','localhost'],true) && (int)$c['port']===5432 && $c['database']==='investemoz' && !$app->configurationIsCached(),RuntimeException::class,'Exact destination required');
throw_unless(config('app.url')==='https://portal-investidor.test' && config('portal.organization_country_enabled') && !config('portal.notifications_enabled') && count(config('portal.notification_recipients',[]))===0,RuntimeException::class,'Preserved URL/country/notification configuration differs');
foreach(['portal.profile_access_enabled','portal.opportunity_dashboard_access_enabled','portal.opportunity_specialized_drafts_enabled','portal.opportunity_modular_profiles_qa_enabled','portal.legacy_content_qa_enabled','investor-path.enabled'] as $flag) throw_unless(!config($flag),RuntimeException::class,'Operational flows must remain OFF');
$pdo=new PDO('pgsql:host='.$c['host'].';port=5432;dbname=investemoz',$c['username'],$c['password'],[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]);
$cloneName=$backup['restored_target']['database'];
throw_unless(preg_match('/^workflow_consolidated_target_\d{8}_\d{6}_test$/D',$cloneName) && $backup['restored_target']['port']===55441,RuntimeException::class,'Exact restored QA clone required');
$clone=new PDO('pgsql:host=127.0.0.1;port=55441;dbname='.$cloneName,'postgres','',[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]);
foreach([$pdo,$clone] as $connection) $connection->exec('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
$identity=$pdo->query("SELECT current_database() AS database,inet_server_port() AS port,current_setting('transaction_read_only') AS read_only")->fetch(PDO::FETCH_ASSOC);
throw_unless($identity['database']==='investemoz' && (int)$identity['port']===5432 && $identity['read_only']==='on'
&& (int)$clone->query('SELECT inet_server_port()')->fetchColumn()===55441,RuntimeException::class);
$quote=static fn(string $v):string=>'"'.str_replace('"','""',$v).'"';
$cloneTables=$clone->query("SELECT schemaname,tablename FROM pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY schemaname,tablename")->fetchAll(PDO::FETCH_ASSOC);
throw_unless(count($cloneTables)===288,RuntimeException::class,'Restored previous schema differs');
$transient=['sessions','cache','cache_locks'];$appendOnly=['audit_events','migrations'];$preserved=0;$transientChanged=[];$appendCounts=[];
foreach($cloneTables as $table){
$name=$quote($table['schemaname']).'.'.$quote($table['tablename']);
$stmt=$clone->prepare('SELECT column_name FROM information_schema.columns WHERE table_schema=? AND table_name=? ORDER BY ordinal_position');$stmt->execute([$table['schemaname'],$table['tablename']]);
$columns=implode(',',array_map($quote,$stmt->fetchAll(PDO::FETCH_COLUMN)));
$sql='SELECT row_to_json(t)::text FROM (SELECT '.$columns.' FROM '.$name.') t ORDER BY row_to_json(t)::text';
$original=$clone->query($sql)->fetchAll(PDO::FETCH_COLUMN);$actual=$pdo->query($sql)->fetchAll(PDO::FETCH_COLUMN);
throw_unless(count($original)===$backup['tables'][$name]['count'] && hash('sha256',implode("\n",$original))===$backup['tables'][$name]['sha256'],RuntimeException::class,'Physical recovery clone rows differ from verified snapshot');
if($table['schemaname']==='public' && in_array($table['tablename'],$transient,true)){
if($actual!==$original) $transientChanged[]=$table['tablename'];
}elseif($table['schemaname']==='public' && in_array($table['tablename'],$appendOnly,true)){
throw_unless(count(array_diff($original,$actual))===0 && count($actual)>=count($original),RuntimeException::class,'Existing audit/migration history changed');
$appendCounts[$table['tablename']]=count($actual)-count($original);
}else{throw_unless($actual===$original,RuntimeException::class,'Original non-transient rows changed');$preserved++;}
}
$actualTables=$pdo->query("SELECT schemaname,tablename FROM pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY schemaname,tablename")->fetchAll(PDO::FETCH_ASSOC);
$expectedNew=['opportunity_documents','opportunity_images','opportunity_location_drafts','portal_profile_requests','portal_role_assignments','portal_superuser_pair','portal_security_approvals'];sort($expectedNew);
$baselineNames=array_map(static fn($t)=>$t['schemaname'].'.'.$t['tablename'],$cloneTables);
$new=array_values(array_diff(array_map(static fn($t)=>$t['schemaname'].'.'.$t['tablename'],$actualTables),$baselineNames));sort($new);
$expectedTables=$phase==='before'?[]:($phase==='resources'?['opportunity_documents','opportunity_images','opportunity_location_drafts']:$expectedNew);
throw_unless($new===array_map(static fn($t)=>'public.'.$t,$expectedTables),RuntimeException::class,'Unexpected schema additions');
throw_unless(count($actualTables)===($phase==='before'?288:($phase==='resources'?291:295)),RuntimeException::class,'Unexpected table count');
$migration030='2026_10_09_030000_extend_opportunity_resources';$migration040='2026_10_10_040000_create_portal_profile_authorization';$migration050='2026_10_10_050000_extend_organization_country';
$actualMigrations=$pdo->query('SELECT migration FROM migrations ORDER BY migration')->fetchAll(PDO::FETCH_COLUMN);
$oldMigrations=$clone->query('SELECT migration FROM migrations ORDER BY migration')->fetchAll(PDO::FETCH_COLUMN);
$newMigrations=array_values(array_diff($actualMigrations,$oldMigrations));sort($newMigrations);
$allowedMigrations=$phase==='before'?[]:($phase==='resources'?[$migration030]:[$migration030,$migration040]);sort($allowedMigrations);
throw_unless($newMigrations===$allowedMigrations && in_array($migration050,$actualMigrations,true) && !in_array('2026_10_09_190000_prepare_investor_path',$actualMigrations,true),RuntimeException::class,'Unexpected migration application');
$sequences=$pdo->query("SELECT schemaname,sequencename FROM pg_sequences WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY schemaname,sequencename")->fetchAll(PDO::FETCH_ASSOC);
$oldSequences=$clone->query("SELECT schemaname,sequencename FROM pg_sequences WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY schemaname,sequencename")->fetchAll(PDO::FETCH_ASSOC);
throw_unless($sequences===$oldSequences && count($sequences)===31,RuntimeException::class,'Sequence inventory differs');
foreach($sequences as $sequence){
$name=$quote($sequence['schemaname']).'.'.$quote($sequence['sequencename']);
$oldSequence=$clone->query('SELECT last_value,is_called FROM '.$name)->fetch(PDO::FETCH_ASSOC);
$actualSequence=$pdo->query('SELECT last_value,is_called FROM '.$name)->fetch(PDO::FETCH_ASSOC);
if($sequence['schemaname']==='public' && $sequence['sequencename']==='migrations_id_seq'){
throw_unless((int)$actualSequence['last_value']===(int)$oldSequence['last_value']+count($allowedMigrations) && $actualSequence['is_called'],RuntimeException::class,'Migration sequence differs from exactly two authorized insertions');
}else throw_unless($actualSequence===$oldSequence,RuntimeException::class,'Original non-migration sequence changed');
}
$counts=[];foreach(['users','organizations','memberships','documents','opportunities','jobs','failed_jobs'] as $name) $counts[$name]=(int)$pdo->query('SELECT count(*) FROM '.$quote($name))->fetchColumn();
throw_unless($counts['users']===13 && $counts['organizations']===0 && $counts['memberships']===0 && $counts['documents']===4 && $counts['jobs']===0 && $counts['failed_jobs']===0,RuntimeException::class,'Destination identities/documents/queue no longer match scope');
$originalBytes=0;
foreach($backup['active_files_manifest'] as $entry=>$hash){if(str_starts_with($entry,'private/')){
$file=$root.'/storage/app/private/'.substr($entry,8);throw_unless(is_file($file) && hash_file('sha256',$file)===$hash,RuntimeException::class,'Original binary changed');$originalBytes++;
}}
throw_unless($originalBytes===4,RuntimeException::class,'Original file inventory differs');
$mfa=$pdo->query('SELECT id FROM users WHERE two_factor_secret IS NOT NULL ORDER BY id')->fetchAll(PDO::FETCH_COLUMN);
throw_unless(array_map('intval',$mfa)===array_map('intval',$backup['original_mfa_user_ids']),RuntimeException::class,'MFA coverage changed');
$pair=null;$audit=[];$addedColumns=0;
if($phase==='resources'){
foreach($expectedTables as $name) throw_unless((int)$pdo->query('SELECT count(*) FROM '.$quote($name))->fetchColumn()===0,RuntimeException::class,'Operational resource records outside scope');
throw_unless((int)$pdo->query('SELECT count(*) FROM documents WHERE storage_encryption IS NOT NULL')->fetchColumn()===0,RuntimeException::class,'Unexpected re-encryption');
$addedColumns=1;
$audit=$pdo->query("SELECT id,source_system,action,actor_user_id FROM audit_events WHERE source_system='opportunity-resources-install' ORDER BY id")->fetchAll(PDO::FETCH_ASSOC);
throw_unless(count($audit)===1 && $audit[0]['action']==='opportunity_resources_installed' && (int)$audit[0]['actor_user_id']===2,RuntimeException::class,'Resource install audit differs');
}elseif($phase!=='before'){
foreach(['opportunity_documents','opportunity_images','opportunity_location_drafts','portal_profile_requests','portal_role_assignments'] as $name) throw_unless((int)$pdo->query('SELECT count(*) FROM '.$quote($name))->fetchColumn()===0,RuntimeException::class,'Operational records/grants created outside scope');
throw_unless((int)$pdo->query('SELECT count(*) FROM documents WHERE storage_encryption IS NOT NULL')->fetchColumn()===0 && (int)$pdo->query('SELECT count(*) FROM users WHERE portal_access_revision<>0')->fetchColumn()===0,RuntimeException::class,'Unexpected re-encryption/access revision');
$stmt=$pdo->query("SELECT table_name,column_name FROM information_schema.columns WHERE table_schema='public' AND ((table_name='documents' AND column_name='storage_encryption') OR (table_name='users' AND column_name='portal_access_revision') OR (table_name='opportunities' AND column_name IN('owner_user_id','organization_id','workflow_context'))) ORDER BY table_name,column_name");
$addedColumns=count($stmt->fetchAll(PDO::FETCH_ASSOC));throw_unless($addedColumns===5,RuntimeException::class,'Expected additive columns missing');
$audit=$pdo->query("SELECT id,source_system,action,actor_user_id FROM audit_events WHERE source_system IN('opportunity-resources-install','portal-profile-installation','portal-profiles-v1') ORDER BY id")->fetchAll(PDO::FETCH_ASSOC);
$actions=array_column($audit,'action');throw_unless(count(array_keys($actions,'opportunity_resources_installed',true))===1 && count(array_keys($actions,'install',true))===1,RuntimeException::class,'Installation audit differs');
$pairs=$pdo->query('SELECT id,first_user_id,second_user_id,approved_by,version FROM portal_superuser_pair')->fetchAll(PDO::FETCH_ASSOC);
if($phase==='bootstrap'){
throw_unless(count($pairs)===1,RuntimeException::class,'Exactly one designated pair required');$pair=array_map('intval',$pairs[0]);
// The real session identifies holder 13 as requester and holder 2 as
// independent confirmer. Preserve the same explicitly designated set.
throw_unless($pair===['id'=>1,'first_user_id'=>13,'second_user_id'=>2,'approved_by'=>2,'version'=>1],RuntimeException::class,'Designated independent pair differs');
$approvals=$pdo->query('SELECT kind,requested_by,subject_user_id,approved_by,consumed_at IS NOT NULL AS consumed FROM portal_security_approvals')->fetchAll(PDO::FETCH_ASSOC);
throw_unless(count($approvals)===1 && $approvals[0]['kind']==='bootstrap' && (int)$approvals[0]['requested_by']===13 && (int)$approvals[0]['subject_user_id']===2 && (int)$approvals[0]['approved_by']===2 && $approvals[0]['consumed'],RuntimeException::class,'Bootstrap approval receipt differs');
foreach(['bootstrap-request','bootstrap-confirm','superuser_pair_initialized'] as $action) throw_unless(count(array_keys($actions,$action,true))===1,RuntimeException::class,'Bootstrap audit missing or duplicated');
}else{throw_unless($pairs===[],RuntimeException::class,'Unexpected prior bootstrap');}
}
$pdo->exec('ROLLBACK');$clone->exec('ROLLBACK');
$proof=['at'=>gmdate('c'),'result'=>'passed','phase'=>$phase,'revision'=>$revision,'target'=>$identity,'schema_tables'=>count($actualTables),'new_tables'=>$new,'added_columns'=>$addedColumns,
'new_migrations'=>$newMigrations,'050000_preserved'=>true,'190000_absent'=>true,'original_tables_compared'=>count($cloneTables),'non_transient_tables_preserved'=>$preserved,'transient_tables_changed'=>$transientChanged,'append_only_counts'=>$appendCounts,
'original_files_unchanged'=>4,'MFA_envelopes_unchanged'=>count($mfa),'COUNTRY02_enabled'=>true,'operational_flags_enabled'=>false,'external_notifications_enabled'=>false,
'counts'=>$counts,'sequences_compared'=>count($sequences),'new_sequences'=>0,'resources_installed'=>$phase!=='before','profiles_installed'=>in_array($phase,['installed','bootstrap'],true),'superuser_pair'=>$pair,'audits'=>$audit,'read_only'=>true,'active_writes'=>0,'queue_jobs_consumed'=>0];
$destination=$private.'/workflow-activation-'.($phase==='installed'?'schema-verified':$phase.'-verified').'.json';
if(is_file($destination)) copy($destination,$destination.'.before-'.gmdate('Ymd_His'));
file_put_contents($destination,json_encode($proof,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo json_encode($proof,JSON_THROW_ON_ERROR).PHP_EOL;