69 lines
13 KiB
PHP
69 lines
13 KiB
PHP
<?php
|
|
// Real restoration of the final functional QA only. Active database is never connected.
|
|
use Illuminate\Support\Facades\DB;
|
|
use Symfony\Component\Process\Process;
|
|
ini_set('zend.exception_ignore_args','1');
|
|
set_exception_handler(function(Throwable $e):void{fwrite(STDERR,'Functional QA restoration not verified: '.$e->getMessage().PHP_EOL);exit(1);});
|
|
$root=dirname(__DIR__);$dir=$root.'/storage/app/private/investmoz-v2/portal-audit';$plan=json_decode(file_get_contents($dir.'/portal-stability-latest.json'),true,512,JSON_THROW_ON_ERROR);
|
|
putenv('STABILITY_QA_MODE=consumers');$app=(static fn(string $file)=>require $file)($plan['source'].'/tools/portal-stability-runtime.php');
|
|
$c=DB::connection()->getConfig();$identity=DB::selectOne('SELECT current_database() AS database, inet_server_port() AS port');
|
|
throw_unless($identity->database===$plan['consumer_environment']['target']['database']&&(int)$identity->port===55441,RuntimeException::class,'Consumer QA identity required.');
|
|
$scope=in_array('--scope',$argv,true);$consolidated=$scope||in_array('--consolidated',$argv,true);
|
|
$folder=$dir.'/functional-restore-'.gmdate('Ymd_His');mkdir($folder,0700,true);$dump=$folder.'/qa.dump';
|
|
$source=DB::connection()->getPdo();$source->exec('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
|
$snapshot=$source->query('SELECT pg_export_snapshot()')->fetchColumn();
|
|
$run=function(array $args)use($folder,$c):void{$p=new Process($args,null,['PGPASSWORD'=>$c['password']??'']);$p->setTimeout(300);$p->run();if(!$p->isSuccessful()){file_put_contents($folder.'/command-failure.json',json_encode(['tool'=>basename($args[0]),'exit_code'=>$p->getExitCode()],JSON_THROW_ON_ERROR));throw new RuntimeException('QA dump/restore command failed; private receipt retained.');}};
|
|
$bin='C:/Program Files/PostgreSQL/17/bin/';$run([$bin.'pg_dump.exe','-h','127.0.0.1','-p','55441','-U',$c['username'],'-d',$identity->database,'-Fc','--snapshot='.$snapshot,'-f',$dump]);
|
|
$name='functional_restore_'.gmdate('Ymd_His').'_test';$connect=fn($db)=>new PDO('pgsql:host=127.0.0.1;port=55441;dbname='.$db,$c['username'],$c['password']??'',[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]);$admin=$connect('postgres');throw_unless((int)$admin->query('SELECT inet_server_port()')->fetchColumn()===55441&&preg_match('/^functional_restore_\d{8}_\d{6}_test$/D',$name),RuntimeException::class,'QA restore target required.');$admin->exec('CREATE DATABASE "'.$name.'"');
|
|
$run([$bin.'pg_restore.exe','-h','127.0.0.1','-p','55441','-U',$c['username'],'-d',$name,'--exit-on-error','--no-owner','--no-privileges',$dump]);$restored=$connect($name);$restored->exec('BEGIN READ ONLY');$tables=[];
|
|
$state=function(PDO $pdo,string $table):array{$pdo->exec('DECLARE functional_backup_rows NO SCROLL CURSOR FOR SELECT row_to_json(t)::text FROM '.$table.' t ORDER BY row_to_json(t)::text');$count=0;$hash=hash_init('sha256');while(($row=$pdo->query('FETCH FORWARD 1 FROM functional_backup_rows')->fetchColumn())!==false){if($count)hash_update($hash,"\n");hash_update($hash,$row);$count++;}$pdo->exec('CLOSE functional_backup_rows');return ['count'=>$count,'sha256'=>hash_final($hash)];};
|
|
foreach($source->query("SELECT schemaname,tablename FROM pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY schemaname,tablename")->fetchAll(PDO::FETCH_ASSOC)as$t){$table='"'.$t['schemaname'].'"."'.$t['tablename'].'"';$a=$state($source,$table);throw_unless($a===$state($restored,$table),RuntimeException::class,'Restored QA table differs.');$tables[$table]=$a;}
|
|
$seqsql="SELECT * FROM pg_sequences WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY schemaname,sequencename";$seq=$source->query($seqsql)->fetchAll(PDO::FETCH_ASSOC);throw_unless($seq===$restored->query($seqsql)->fetchAll(PDO::FETCH_ASSOC),RuntimeException::class,'QA sequences differ.');
|
|
$zip=new ZipArchive;$files=$folder.'/files.zip';throw_unless($zip->open($files,ZipArchive::CREATE|ZipArchive::EXCL)===true,RuntimeException::class,'QA archive refused.');$manifest=[];$filesRoot=realpath(storage_path('app/private'));$missing=[];
|
|
foreach($source->query('SELECT DISTINCT storage_key FROM documents WHERE storage_key IS NOT NULL')->fetchAll(PDO::FETCH_COLUMN)as$key){$file=realpath(storage_path('app/private/'.$key));if(!$file){$missing[]=$key;continue;}throw_unless(str_starts_with(strtolower($file),strtolower($filesRoot.DIRECTORY_SEPARATOR)),RuntimeException::class,'QA file outside private root.');$entry='private/'.str_replace('\\','/',$key);$zip->addFile($file,$entry);$manifest[$entry]=hash_file('sha256',$file);}
|
|
throw_unless($missing===[],RuntimeException::class,'Referenced QA original absent; do not claim backup verified.');
|
|
$env=$plan['source'].'/.env.stability-consumers';$zip->addFile($env,'runtime/qa.env');$manifest['runtime/qa.env']=hash_file('sha256',$env);
|
|
$legacyRuntime=$root.'/tools/portal-qa-environment.php';$zip->addFile($legacyRuntime,'runtime/historical-qa-encryption.php');$manifest['runtime/historical-qa-encryption.php']=hash_file('sha256',$legacyRuntime);
|
|
// This clone contains historic active envelopes. Read only the corresponding key
|
|
// material; never boot or connect to the active application/database or change keys.
|
|
$activeEnv=Dotenv\Dotenv::parse(file_get_contents($root.'/.env'));
|
|
$capsule=$folder.'/historical-source-encryption.json';
|
|
file_put_contents($capsule,json_encode(['cipher'=>$activeEnv['APP_CIPHER']??'AES-256-CBC','keys'=>array_values(array_filter([$activeEnv['APP_KEY']??null,...explode(',',$activeEnv['APP_PREVIOUS_KEYS']??'')]))],JSON_THROW_ON_ERROR));
|
|
$zip->addFile($capsule,'runtime/historical-source-encryption.json');$manifest['runtime/historical-source-encryption.json']=hash_file('sha256',$capsule);
|
|
foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($plan['source'].'/public/build',FilesystemIterator::SKIP_DOTS))as$file){if(!$file->isFile())continue;$entry='build/'.str_replace('\\','/',substr($file->getPathname(),strlen($plan['source'].'/public/build/')));$zip->addFile($file->getPathname(),$entry);$manifest[$entry]=hash_file('sha256',$file->getPathname());}
|
|
$code=[];$acervo=null;
|
|
if($consolidated){
|
|
$prior=json_decode(file_get_contents($root.'/outputs/investmoz-opportunity-workflow-candidate-20261011/manifesto.json'),true,512,JSON_THROW_ON_ERROR);
|
|
$payload=$scope?json_decode(file_get_contents($dir.'/functional-delivery-scope-code-20261011.json'),true,512,JSON_THROW_ON_ERROR)['code_files']:$prior['payload'];
|
|
foreach($payload as$key=>$hash){if(!str_starts_with($key,'app/')&&!str_starts_with($key,'resources/'))continue;$file=$plan['source'].'/'.$key;$entry='candidate-code/'.$key;$code[$key]=hash_file('sha256',$file);$zip->addFile($file,$entry);$manifest[$entry]=$code[$key];}
|
|
ksort($code);$codeRevision=hash('sha256',json_encode($code,JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR));
|
|
$expected=$scope?json_decode(file_get_contents($dir.'/functional-delivery-scope-code-20261011.json'),true,512,JSON_THROW_ON_ERROR)['code_revision']:'f07f34a11eabc35d13d3bae88df2c10f1420f582e4600dafa88628dde6195612';
|
|
throw_unless($codeRevision===$expected,RuntimeException::class,'Consolidated code differs; do not attach backup to another revision.');
|
|
$acervo=['all_referenced_QA_originals_included'=>true,'source_document'=>['resource_id'=>29,'document_id'=>'589ed9ac-967e-439e-9dde-b0fb3996e546','sha256'=>'bf26caeee0437a8d292bf29be59247e2862c14e79b5ae465c00322747af72af5'],'lease_fixture'=>54,'synthetic_QA_data_not_for_active_import'=>true];
|
|
$doc=$source->query("SELECT d.storage_key,d.sha256,d.malware_scan_status,r.metadata_version,r.published FROM documents d JOIN portal_resources r ON r.document_id=d.id WHERE r.id=29")->fetch(PDO::FETCH_ASSOC);
|
|
throw_unless($doc&&$doc['sha256']===$acervo['source_document']['sha256']&&(int)$doc['metadata_version']===3&&!$doc['published']&&$doc['malware_scan_status']==='clean'&&($manifest['private/'.$doc['storage_key']]??null)===$doc['sha256'],RuntimeException::class,'Source acervo original/version not covered.');
|
|
$acervo['source_document']['metadata_version']=3;$acervo['source_document']['published']=false;
|
|
$leaseDocs=$source->query("SELECT od.id AS resource_id,d.sha256,d.malware_scan_status,d.storage_encryption FROM opportunity_documents od JOIN documents d ON d.id=od.document_id WHERE od.opportunity_id=54 AND od.deleted_at IS NULL ORDER BY od.id")->fetchAll(PDO::FETCH_ASSOC);
|
|
throw_unless(count($leaseDocs)===3&&count(array_filter($leaseDocs,fn($d)=>$d['malware_scan_status']==='clean'))===3,RuntimeException::class,'Own lease QA documents not all covered.');$acervo['lease_documents']=$leaseDocs;
|
|
$lease=$source->query("SELECT workflow_context->>'working_state' AS state FROM opportunities WHERE id=54")->fetchColumn();
|
|
throw_unless($lease==='published',RuntimeException::class,'Complete lease QA demonstration required before renewal.');
|
|
}
|
|
$zip->close();
|
|
$read=new ZipArchive;$read->open($files);$restore=$folder.'/restored-files';mkdir($restore,0700,true);foreach($manifest as$entry=>$hash){throw_unless(!str_contains($entry,':')&&!str_starts_with($entry,'/')&&!array_intersect(explode('/',$entry),['.','..']),RuntimeException::class,'Archive path refused.');$file=$restore.'/'.$entry;if(!is_dir(dirname($file)))mkdir(dirname($file),0700,true);$bytes=$read->getFromName($entry);file_put_contents($file,$bytes);throw_unless(hash_file('sha256',$file)===$hash,RuntimeException::class,'Restored bytes differ.');}$read->close();
|
|
$mfa=0;$legacyMfa=[];$unreadable=[];$negativeControls=[];preg_match('/base64:([A-Za-z0-9+\/=]{40,})/',file_get_contents($restore.'/runtime/historical-qa-encryption.php'),$legacyMatch);
|
|
$crypts=['current_qa'=>app('encrypter')];
|
|
if(isset($legacyMatch[1]))$crypts['historical_qa']=new Illuminate\Encryption\Encrypter(base64_decode($legacyMatch[1],true),config('app.cipher'));
|
|
$retained=json_decode(file_get_contents($restore.'/runtime/historical-source-encryption.json'),true,512,JSON_THROW_ON_ERROR);
|
|
foreach($retained['keys']as$i=>$key)$crypts['historical_source_'.$i]=new Illuminate\Encryption\Encrypter(str_starts_with($key,'base64:')?base64_decode(substr($key,7),true):$key,$retained['cipher']);
|
|
foreach($restored->query('SELECT id,name,email,two_factor_secret FROM users WHERE two_factor_secret IS NOT NULL')->fetchAll(PDO::FETCH_ASSOC)as$row){$plain=null;$matched=null;foreach($crypts as$label=>$crypt){try{$plain=$crypt->decrypt($row['two_factor_secret'],false);$matched=$label;break;}catch(Illuminate\Contracts\Encryption\DecryptException){continue;}}if($matched===null){
|
|
// Original fixture from portal-qa-mfa.php deliberately uses a foreign key.
|
|
// Restoring it must preserve rejection, rather than silently repairing MFA.
|
|
if((int)$row['id']===19 && $row['name']==='Synthetic wrong-key MFA negative control QA' && $row['email']==='mfa-negative-qa@example.invalid'){$negativeControls[]=['actor_id'=>19,'expected_decryption_rejection_preserved'=>true,'authenticated'=>false];continue;}
|
|
$unreadable[]=(int)$row['id'];continue;
|
|
}if($matched!=='current_qa')$legacyMfa[]=['actor_id'=>(int)$row['id'],'key_source'=>$matched];throw_unless(is_string($plain)&&$plain!=='',RuntimeException::class,'Restored MFA envelope empty.');$mfa++;}
|
|
throw_unless($unreadable===[],RuntimeException::class,'MFA unreadable with corresponding retained keys; actor IDs: '.implode(',',$unreadable));
|
|
$source->exec('ROLLBACK');$restored->exec('ROLLBACK');
|
|
$proof=['result'=>'passed','at'=>gmdate('c'),'source'=>$identity,'restored'=>['database'=>$name,'port'=>55441],'dump_path'=>$dump,'dump_sha256'=>hash_file('sha256',$dump),'files_path'=>$files,'files_sha256'=>hash_file('sha256',$files),'tables'=>count($tables),'sequences'=>count($seq),'files'=>count($manifest),'MFA_envelopes_readable'=>$mfa,'historical_MFA_key_actor_ids'=>$legacyMfa,'intentional_negative_controls'=>$negativeControls,'historical_cipher_bytes_unchanged'=>true,'no_runtime_key_changes'=>true,'table_snapshot'=>$tables,'sequence_snapshot'=>$seq,'file_manifest'=>$manifest,'build_manifest_sha256'=>hash_file('sha256',$plan['source'].'/public/build/manifest.json'),'profile_fingerprint'=>app(App\Services\PortalProfileInstallation::class)->fingerprint(),'active_writes'=>0,'scope'=>'Final consumer QA, not activation-target backup'];
|
|
if($consolidated){$proof['code_revision']=$codeRevision;$proof['code_manifest']=$code;$proof['acervo']=$acervo;$proof['previous_backup_preserved']=true;}
|
|
file_put_contents($folder.'/proof.json',json_encode($proof,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));$receipt=$scope?'functional-scope-backup-restoration-qa-20261011.json':($consolidated?'functional-consolidated-backup-restoration-qa-20261011.json':'functional-backup-restoration-qa-20261011.json');file_put_contents($dir.'/'.$receipt,json_encode($proof,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo json_encode(array_diff_key($proof,array_flip(['table_snapshot','sequence_snapshot','file_manifest','dump_path','files_path','code_manifest'])),JSON_THROW_ON_ERROR).PHP_EOL;
|