Files
preland/admin/tests/copy.php
T

76 lines
15 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,Store,Transport,Jobs,Copy,Problem,IndpicSetStorage};
function copyCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS {$message}\n";}
$root=sys_get_temp_dir().'/preland-copy-'.bin2hex(random_bytes(6));mkdir($root,0700,true);
$store=new Store($root.'/state');$paths=[];
foreach(['source','a','b','c'] as $id){$paths[$id]=$root.'/'.$id;mkdir($paths[$id],0700);}
putenv('ADMIN_LOCAL_ROOTS='.implode(',',$paths));
foreach($paths as $id=>$path){$store->saveTarget(['id'=>$id,'domain'=>$id.'.invalid','type'=>'local','root'=>$path,'enabled'=>true]);$store->verifyTarget($id);}
function fixture(string $id,string $json): void {global $paths;file_put_contents($paths[$id].'/config.json',$json);}
function readCopy(string $id): array {global $store;return (new Transport($store,$store->target($id)))->readConfig();}
function selection(string $id): object {return (object)['id'=>$id,'version'=>readCopy($id)['version']];}
function copyBody(array $ids=['a']): array {return ['source'=>selection('source'),'targets'=>array_map('selection',$ids)];}
$source='{"host":"saved.example","requisites":["source"],"extra":{"source":true},"utm":{"registration":{"default":"after","weights":{"before":75,"after":25},"source_only":true},"theme":{"default":2,"weights":{"1":0,"2":100}},"bchoice":{"default":"a","weights":{"b":66.67,"a":33.33}},"focus":{"default":"face","weights":{"face":0,"center":0}},"unknown":{"raw":"source"}}}';
$target='{"host":"old.example","requisites":["target"],"extra":{"empty":{},"array":[],"flag":false,"zero":0},"utm":{"reg":{"default":1,"weights":{"1":100},"target_only":{"keep":true}},"theme":{"default":"dark","weights":{"dark":100}},"chob":{"default":"b","weights":{"b":100}},"indpic":{"default":"9","weights":{"9":0}},"unknown":{"raw":"target"}}}';
// Missing source indpic must preserve it; use a valid recipient without indpic for initial success.
$target=str_replace(',"indpic":{"default":"9","weights":{"9":0}}','',$target);
fixture('source',$source);foreach(['a','b','c'] as $id)fixture($id,$target);
$port=random_int(19000,29000);$env=getenv();$env['ADMIN_STATE_DIR']=$store->dir;$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='copy-admin';$env['ADMIN_PASSWORD_HASH']=password_hash('copy-fixture-password',PASSWORD_DEFAULT);
$log=fopen($root.'/http.log','a');$server=proc_open([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',dirname(__DIR__).'/public',dirname(__DIR__).'/public/index.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);$cookie='';
function copyRequest(array $body,?string $csrf=null,string $path='/api/copy'): array {global $port,$cookie;$headers=['Content-Type: application/json'];if($cookie)$headers[]='Cookie: '.$cookie;if($csrf!==null)$headers[]='X-CSRF-Token: '.$csrf;$ctx=stream_context_create(['http'=>['method'=>'POST','header'=>implode("\r\n",$headers),'content'=>json_encode($body,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>10]]);$raw=file_get_contents('http://127.0.0.1:'.$port.$path,false,$ctx);preg_match('~HTTP/\S+ (\d+)~',$http_response_header[0]??'',$match);foreach($http_response_header as $header)if(str_starts_with(strtolower($header),'set-cookie: '))$cookie=explode(';',substr($header,12))[0];return [(int)($match[1]??0),json_decode((string)$raw,true,64,JSON_THROW_ON_ERROR)];}
try{
$deadline=microtime(true)+5;do{$socket=@fsockopen('127.0.0.1',$port);if($socket){fclose($socket);break;}usleep(20000);}while(microtime(true)<$deadline);
[$status]=copyRequest(copyBody());copyCheck($status===401,'copy requires authentication');
[$status,$body]=copyRequest(['username'=>'copy-admin','password'=>'copy-fixture-password'],null,'/api/login');$csrf=$body['csrf'];
[$status]=copyRequest(copyBody());copyCheck($status===403,'copy requires CSRF');
[$status,$body]=copyRequest(copyBody(['a','b']),$csrf);copyCheck($status===202&&isset($body['job']['id']),'HTTP copy queues server-owned saved snapshot');$job=$body['job'];$jobs=new Jobs($store);
fixture('source',str_replace('saved.example','changed.example',$source));
$staleTarget=readCopy('b');fixture('b',str_replace('old.example','foreign.example',$target));
$jobs->run($job['id']);$done=$jobs->get($job['id']);copyCheck($done['results'][0]['status']==='success'&&$done['results'][1]['status']==='conflict','copy isolates success and target CAS conflict');
$a=readCopy('a')['config'];copyCheck($a->host==='saved.example'&&readCopy('b')['config']->host==='foreign.example','queued source snapshot is immutable and conflict target untouched');
copyCheck($a->utm->reg->default==='2'&&$a->utm->reg->weights->{'1'}===75&&$a->utm->reg->weights->{'2'}===25&&$a->utm->reg->target_only->keep===true,'source semantic registration projects to target numeric reg and preserves target group metadata');
copyCheck($a->utm->theme->default==='light'&&$a->utm->theme->weights->dark===0&&$a->utm->theme->weights->light===100&&$a->utm->chob->default==='a'&&!property_exists($a->utm,'bchoice'),'target semantic schema and alias preserved');
copyCheck($a->utm->focus->default==='1'&&$a->utm->focus->weights->{'1'}===0&&$a->utm->focus->weights->{'2'}===0,'missing recipient group added canonically and all-zero weights retained');
copyCheck($a->requisites===['target']&&$a->extra->empty instanceof stdClass&&$a->extra->array===[]&&$a->extra->flag===false&&$a->extra->zero===0&&$a->utm->unknown->raw==='target'&&!property_exists($a->utm->reg,'source_only'),'noncopy fields and JSON types preserved, source unknown fields excluded');
copyCheck(count($store->backups('a'))===1&&$store->backupContent('a',$store->backups('a')[0]['id'])===$target&&$store->backups('b')===[],'successful target gets exact backup and conflict gets none');
$old=copyBody();fixture('source',str_replace('changed.example','newer.example',file_get_contents($paths['source'].'/config.json')));$before=count(glob($store->dir.'/jobs/*.json'));[$status]=copyRequest($old,$csrf);copyCheck($status===409&&count(glob($store->dir.'/jobs/*.json'))===$before,'source version conflict returns HTTP 409 before enqueue');
$valid=copyBody();$invalid=[[],array_merge($valid,['config'=>(object)[]]),array_replace($valid,['source'=>['source']]),array_replace($valid,['targets'=>(object)['a'=>selection('a')]]),array_replace($valid,['targets'=>[]]),array_replace($valid,['targets'=>[selection('a'),selection('a')]]),array_replace($valid,['targets'=>[selection('source')]]),array_replace($valid,['targets'=>array_fill(0,101,selection('a'))])];
foreach(['id'=>1,'version'=>123,'extra'=>true] as $key=>$value){$bad=clone $valid['source'];$bad->$key=$value;$invalid[]=array_replace($valid,['source'=>$bad]);$bad=clone $valid['targets'][0];$bad->$key=$value;$invalid[]=array_replace($valid,['targets'=>[$bad]]);}
foreach(['','abc',str_repeat('G',64)] as $version){$bad=clone $valid['source'];$bad->version=$version;$invalid[]=array_replace($valid,['source'=>$bad]);}
foreach($invalid as $i=>$request){[$status]=copyRequest($request,$csrf);copyCheck($status===422,'strict copy JSON rejects invalid shape/types/unknown keys/duplicates '.$i);}
foreach(['source','a'] as $id)foreach(['enabled','verified'] as $field){$registry=$store->read('targets.json');$registry[$id][$field]=false;$store->write('targets.json',$registry);[$status]=copyRequest(copyBody(),$csrf);copyCheck($status===422,'copy rejects '.$field.' false for '.$id);$registry[$id][$field]=true;$store->write('targets.json',$registry);}
foreach(['{"utm":{"chob":{"default":"a","weights":{"a":100}},"bchoice":{"default":"a","weights":{"a":100}}}}','{"utm":{"reg":null}}','{"utm":{"theme":{"default":"dark","weights":{"dark":33.33,"1":66.67}}}}','{"utm":{"focus":{"default":true,"weights":{"1":100}}}}','{"host":null}','{"utm":[]}'] as $json){fixture('source',$json);[$status]=copyRequest(copyBody(),$csrf);copyCheck($status===422,'source invalid/ambiguous canonical structure rejected before job');}
fixture('source','{"utm":{"reg":{"default":"before","weights":{"before":100}}}}');fixture('c','{"host":"keep.example","utm":{"focus":{"default":2,"weights":{"2":0},"meta":[]}},"x":{}}');
[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);$c=readCopy('c')['config'];copyCheck($c->host==='keep.example'&&$c->utm->focus->default===2&&$c->utm->focus->meta===[]&&$c->x instanceof stdClass,'missing source host/groups leave recipient values and types unchanged');
fixture('source','{"utm":{"indpic":{"default":"1","weights":{"1":100}}}}');fixture('c','{"utm":{}}');[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobId=$body['job']['id'];$before=readCopy('c')['content'];$backupCount=count($store->backups('c'));$jobs->run($jobId);copyCheck($jobs->get($jobId)['results'][0]['status']==='success'&&readCopy('c')['config']->utm->indpic->default==='1'&&count($store->backups('c'))===$backupCount+1&&!is_dir($paths['c'].'/1'),'copy writes manual indpic without requiring physical images');
mkdir($paths['c'].'/1',0700);foreach(IndpicSetStorage::expectedDimensions() as $name=>$size){$image=new Imagick();$image->newImage($size['width'],$size['height'],'#777777');$image->setImageFormat(pathinfo($name,PATHINFO_EXTENSION));file_put_contents($paths['c'].'/1/'.$name,$image->getImagesBlob());$image->clear();}
fixture('source','{"host":"both.example","utm":{"tp":{"default":"2","weights":{"2":100}},"cons":{"default":1,"weights":{"1":0}},"chb":null}}');
[$status,$body]=copyRequest(copyBody(['a','c']),$csrf);$both=$body['job']['id'];$cBefore=readCopy('c');$jobs->run($both);$done=$jobs->get($both);$c=readCopy('c');
copyCheck($done['results'][0]['status']==='success'&&$done['results'][1]['status']==='success'&&readCopy('a')['config']->utm->tp->weights->{'2'}===100&&$c['config']->utm->cons->default===1,'one saved snapshot copies to two successful recipients including tp/cons');
copyCheck($c['config']->utm->indpic->default==='1'&&$c['config']->utm->indpic->weights->{'1'}===100&&!property_exists($c['config']->utm,'chb')&&is_dir($paths['c'].'/1'),'absent source indpic preserves valid recipient group/set; source chb is excluded');
$backupCount=count($store->backups('c'));$record=$store->read('jobs/'.$both.'.json');$record['status']='running';$record['results'][1]=['target'=>'c','status'=>'running','desired_hash'=>$c['version']];$store->write('jobs/'.$both.'.json',$record);$jobs->run($both);
copyCheck($jobs->get($both)['results'][1]['status']==='success'&&readCopy('c')['content']===$c['content']&&count($store->backups('c'))===$backupCount,'copy desired-hash recovery confirms already-written config without second write or backup');
$rejected=false;try{$jobs->retry($both,['a','c']);}catch(Problem $e){$rejected=$e->status===422;}
copyCheck($rejected&&count($store->backups('c'))===$backupCount,'successful copy recipients cannot be retried and do not acquire duplicate backups');
fixture('source','{"host":"catalog.example","utm":{"degr":{"default":"2","weights":{"2":100}}}}');fixture('c','{"host":"old.example","utm":{}}');$store->write('catalog.json',['host'=>'catalog.example','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[['number'=>1,'name'=>'one']],'tp'=>[],'cons'=>[]]]);[$status,$body]=copyRequest(copyBody(['c']),$csrf);$before=readCopy('c')['content'];$jobs->run($body['job']['id']);copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='success'&&readCopy('c')['config']->utm->degr->default==='2','copy ignores catalog and preserves manually selected group');
fixture('source','{"utm":{"theme":{"default":"3","weights":{"1":0,"2":0,"3":100}}}}');fixture('c','{"utm":{"theme":{"default":"light","weights":{"dark":0,"light":100},"extra":"keep"},"focus":{"default":"2","weights":{"2":100}}}}');
[$status,$body]=copyRequest(copyBody(['c']),$csrf);copyCheck($status===202,'copy queues arbitrary theme3');$jobs->run($body['job']['id']);$copied=readCopy('c')['config'];
copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='success'&&$copied->utm->theme->default==='3'&&get_object_vars($copied->utm->theme->weights)===['dark'=>0,'light'=>0,3=>100]&&$copied->utm->theme->extra==='keep'&&$copied->utm->focus->default==='2','copy keeps numeric theme3 beside semantic aliases and retains recipient-only group');
fixture('source','{"host":"safe.example"}');fixture('c','{"utm":{"chb":null}}');[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='error'&&property_exists(readCopy('c')['config']->utm,'chb'),'copy preserves obsolete chb write guard rather than silently deleting it');
fixture('c','{"utm":{"chob":{"default":"a","weights":{"a":100}},"bchoice":{"default":"b","weights":{"b":100}}}}');[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='error','ambiguous target aliases rejected per domain even if source group absent');
fixture('c','{"host":"safe.example"}');$backup=$store->backup('c','{"utm":{"chb":null}}');$restore=$jobs->create('restore',['target'=>'c','version'=>readCopy('c')['version'],'id'=>$backup]);$jobs->run($restore['id']);copyCheck($jobs->get($restore['id'])['results'][0]['status']==='error'&&readCopy('c')['config']->host==='safe.example','restore retains chb publication guard after copy integration');
fixture('source','{"utm":{"reg":{"default":"1","weights":{"1":3,"2":1}}}}');
$jobCount=count(glob($store->dir.'/jobs/*.json'));[$status]=copyRequest(copyBody(['c']),$csrf);
copyCheck($status===422&&count(glob($store->dir.'/jobs/*.json'))===$jobCount,'relative source weights rejected by HTTP copy before enqueue');
fixture('source','{"host":"safe.example"}');fixture('c','{"utm":{"theme":{"default":"dark","weights":{"dark":1}}}}');
$before=readCopy('c')['content'];$backupCount=count($store->backups('c'));[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);
copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='error'&&readCopy('c')['content']===$before&&count($store->backups('c'))===$backupCount,'copy rejects retained relative target group before write or backup');
$registry=$store->read('targets.json');$registry['c']['verified']=false;$store->write('targets.json',$registry);[$status]=copyRequest(['id'=>'c'],$csrf,'/api/targets/test');
copyCheck($status===422&&!$store->target('c')['verified'],'connection HTTP test cannot mark relative runtime as verified');
fixture('c','{"utm":{"theme":{"default":"dark","weights":{"dark":100}}}}');[$status]=copyRequest(['id'=>'c'],$csrf,'/api/targets/test');
copyCheck($status===200&&$store->target('c')['verified'],'connection verification recovers with valid percentage runtime');
echo "Copy HTTP/jobs contract complete\n";
}finally{proc_terminate($server);proc_close($server);fclose($log);}