Files

104 lines
12 KiB
PHP

<?php
if (getenv('EXOPLASIM_API_TEST_CHILD') === '1') {
parse_str(getenv('EXOPLASIM_API_TEST_QUERY') ?: '', $_GET);
$_POST=[];
$_SERVER['REQUEST_METHOD']='GET';
$_SERVER['SCRIPT_NAME']='/exoplasim/web/index.php';
$_SERVER['PHP_SELF']='/exoplasim/web/index.php';
$_SERVER['HTTP_HOST']='localhost';
$_SERVER['HTTPS']='on';
require __DIR__.'/../web/index.php';
exit;
}
require_once __DIR__.'/../web/lib/labdb.class.php';
function check(bool $ok,string $message): void { if(!$ok)throw new RuntimeException($message); }
function request_api(array $query): string {
putenv('EXOPLASIM_API_TEST_CHILD=1');
putenv('EXOPLASIM_API_TEST_QUERY='.http_build_query($query));
$pipes=[];$process=proc_open([PHP_BINARY,__FILE__],[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes,dirname(__DIR__));
if(!is_resource($process))throw new RuntimeException('Could not start PHP route fixture.');
fclose($pipes[0]);$body=stream_get_contents($pipes[1]);$error=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);$status=proc_close($process);
putenv('EXOPLASIM_API_TEST_CHILD');putenv('EXOPLASIM_API_TEST_QUERY');
if($status!==0)throw new RuntimeException('PHP route fixture failed: '.$error);
return $body;
}
function reply_data(string $body): array {
$reply=json_decode($body,true);
check(is_array($reply),'Expected a JSON response.');
return $reply;
}
function create_job(PDO $db,string $id,string $kind,string $spec,string $now): void {
$s=$db->prepare("INSERT INTO jobs(id,owner,name,spec,kind,status,total_cycles,created_at,updated_at) VALUES(?,?,?,?,?,'completed',1,?,?)");
$s->execute([$id,'local',$kind.' test',$spec,$kind,$now,$now]);
}
function write_json(string $path,array $value): void { file_put_contents($path,json_encode($value,JSON_THROW_ON_ERROR)); }
$root=dirname(__DIR__);$tmp=sys_get_temp_dir().'/exoplasim-api-'.bin2hex(random_bytes(5));mkdir($tmp,0700,true);
foreach(['catalog','jobs','runtime','data'] as $dir)mkdir($tmp.'/'.$dir,0750,true);
$config=json_decode(file_get_contents($root.'/config/web.json'),true,512,JSON_THROW_ON_ERROR);
$config['storage']['root']=$tmp;$config['storage']['catalog']='catalog/parameters.json';$config['max_result_bytes']=256;$config['max_field_memory_bytes']=64;
file_put_contents($tmp.'/config.json',json_encode($config,JSON_THROW_ON_ERROR));putenv('EXOPLASIM_CONFIG='.$tmp.'/config.json');
try {
$command=escapeshellarg($root.'/.venv/bin/python').' '.escapeshellarg($root.'/scripts/web/parameters.py').' --config '.escapeshellarg($tmp.'/config.json').' --output '.escapeshellarg($tmp.'/catalog/parameters.json').' 2>&1';
exec($command,$output,$status);check($status===0,'Could not generate the test catalog: '.implode("\n",$output));
$db=LabDb::connection();$now=gmdate('c');$modelId=str_repeat('a',32);$modelDataset=str_repeat('c',32);$planetId=str_repeat('b',32);$planetDataset=str_repeat('d',32);$token=str_repeat('e',32);
$spec=json_encode(['model'=>[],'run'=>['cycles'=>1],'parameters'=>(object)[],'maps'=>['height'=>$token]],JSON_THROW_ON_ERROR);
create_job($db,$modelId,'model',$spec,$now);create_job($db,$planetId,'planet',$spec,$now);
$modelRoot=$tmp.'/jobs/'.$modelId;$modelArtifacts=$modelRoot.'/artifacts';$modelData=$modelArtifacts.'/datasets/'.$modelDataset;
mkdir($modelData,0750,true);mkdir($modelRoot.'/runtime',0750,true);mkdir($modelRoot.'/inputs/maps',0750,true);mkdir($modelRoot.'/inputs/native',0750,true);
file_put_contents($modelRoot.'/inputs/maps/height.f32','fixture input map');file_put_contents($modelRoot.'/inputs/native/height.sra','fixture native input');
file_put_contents($modelRoot.'/worker.log','fixture worker log');file_put_contents($modelRoot.'/runtime/GUI.cfg','fixture model config');file_put_contents($modelRoot.'/runtime/MOST.00000.npz','fixture raw model NPZ');file_put_contents($modelRoot.'/runtime/MOST.00000_metadata.npz','fixture raw model metadata');file_put_contents($modelRoot.'/runtime/plasim_restart','fixture restart');file_put_contents($modelRoot.'/runtime/MOST_REST.00000','fixture cycle restart');
$index=['id'=>$modelDataset,'lat'=>[-30,30],'lon'=>[0,120,240],'time'=>[0,1],'time_unit'=>'years','levels'=>[0],'fields'=>[['name'=>'temperature','label'=>'Temperature','unit'=>'K','shape'=>[2,1,2,3]]]];
write_json($modelData.'/index.json',$index);write_json($modelData.'/temperature.json',['mean'=>[[280],[281]],'min'=>[[270],[271]],'max'=>[[290],[291]]]);$fieldBytes=pack('g*',1,2,3,4,5,6,7,8,9,10,11,12);file_put_contents($modelData.'/temperature.f32',$fieldBytes);
write_json($modelArtifacts.'/index.json',['datasets'=>[['id'=>$modelDataset,'label'=>'Cycle 1']],'latest'=>$modelDataset]);
$planetRoot=$tmp.'/jobs/'.$planetId;$planetArtifacts=$planetRoot.'/artifacts';$planetData=$planetArtifacts.'/datasets/'.$planetDataset;
mkdir($planetData,0750,true);mkdir($planetArtifacts.'/planet-work',0750,true);mkdir($planetRoot.'/runtime',0750,true);
file_put_contents($planetRoot.'/worker.log','planet worker log');file_put_contents($planetArtifacts.'/planet-work/surface-'.$token.'.npz','fixture terrain NPZ');file_put_contents($planetArtifacts.'/planet-work/surface-'.$token.'_metadata.npz','fixture terrain metadata');file_put_contents($planetArtifacts.'/planet.json','fixture terrain recipe');file_put_contents($planetArtifacts.'/planet-work/N064_surf_0129.sra','fixture topography SRA');file_put_contents($planetArtifacts.'/planet-work/N064_surf_0172.sra',str_repeat('s',257));
$planetIndex=['id'=>$planetDataset,'lat'=>[-30,30],'lon'=>[0,120,240],'time'=>[0],'time_unit'=>'','levels'=>[0],'fields'=>[['name'=>'elevation','label'=>'Elevation','unit'=>'m','shape'=>[1,1,2,3]]]];
write_json($planetData.'/index.json',$planetIndex);write_json($planetArtifacts.'/index.json',['datasets'=>[['id'=>$planetDataset,'label'=>'Generated planet surface']],'latest'=>$planetDataset]);
$schema=reply_data(request_api(['api'=>'schema']));check($schema['ok']===true&&isset($schema['data']['fields']),'The ordinary api=schema query must resolve.');
$unknown=reply_data(request_api(['api'=>'not_found']));check(empty($unknown['ok'])&&isset($unknown['errors']['route']),'Unknown query operations must return a structured error.');
$modelResults=reply_data(request_api(['api'=>'results','id'=>$modelId]));check($modelResults['ok']===true,'The query-param results route must resolve.');$modelLinks=array_column($modelResults['data']['downloads'],'id');
foreach(['log','model-config','raw:'.$modelDataset,'raw-metadata:'.$modelDataset,'dataset-index:'.$modelDataset,'restart:plasim_restart','restart:MOST_REST.00000','field:'.$modelDataset.':temperature'] as $link)check(in_array($link,$modelLinks,true),'Results must list '.$link.'.');
foreach(['input-map:height','native-input:height'] as $link)check(in_array($link,$modelLinks,true),'Results must list '.$link.'.');
check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'input-map:height'])==='fixture input map','Captured map download must work.');
check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'native-input:height'])==='fixture native input','Retained native input download must work.');
check(empty(reply_data(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'native-input:unknown']))['ok']),'Unknown native inputs must fail.');
check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'log'])==='fixture worker log','Log download must work without dataset or field parameters.');
check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'model-config'])==='fixture model config','Model config download must work.');
check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'raw:'.$modelDataset])==='fixture raw model NPZ','Raw model output download must work.');
check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'raw-metadata:'.$modelDataset])==='fixture raw model metadata','Raw model metadata download must work.');
check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'dataset-index:'.$modelDataset])===file_get_contents($modelData.'/index.json'),'Dataset index download must work.');
check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'restart:plasim_restart'])==='fixture restart','Model restart download must work.');
check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'restart:MOST_REST.00000'])==='fixture cycle restart','Cycle restart download must work.');
check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'field:'.$modelDataset.':temperature'])===$fieldBytes,'Field download must return the allowlisted binary field.');
$missing=reply_data(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'field:'.$modelDataset.':missing']));check(empty($missing['ok']),'Missing field download must fail.');
$arbitrary=reply_data(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'../../etc/passwd']));check(empty($arbitrary['ok']),'Arbitrary paths must not be downloadable.');
$frame=reply_data(request_api(['api'=>'frame','id'=>$modelId,'dataset'=>$modelDataset,'field'=>'temperature','time'=>2,'level'=>0]));check(empty($frame['ok'])&&isset($frame['errors']['selection']),'Out-of-range frames must fail.');
check(request_api(['api'=>'frame','id'=>$modelId,'dataset'=>$modelDataset,'field'=>'temperature','time'=>0,'level'=>0])===substr($fieldBytes,0,24),'Valid frames must return one binary frame.');
$secondDataset=str_repeat('f',32);$secondData=$modelArtifacts.'/datasets/'.$secondDataset;mkdir($secondData,0750,true);$secondIndex=$index;$secondIndex['id']=$secondDataset;
write_json($secondData.'/index.json',$secondIndex);file_put_contents($secondData.'/temperature.f32',$fieldBytes);file_put_contents($modelRoot.'/runtime/MOST.00001.npz','second cycle NPZ');
write_json($modelArtifacts.'/index.json',['datasets'=>[['id'=>$modelDataset,'label'=>'Cycle 1'],['id'=>$secondDataset,'label'=>'Cycle 2']],'latest'=>$secondDataset]);
$secondResults=reply_data(request_api(['api'=>'results','id'=>$modelId]));$secondLinks=array_column($secondResults['data']['downloads'],'id');
check(in_array('raw:'.$secondDataset,$secondLinks,true),'Latest-cycle results must list their own raw output.');
check(!in_array('raw:'.$modelDataset,$secondLinks,true)&&!in_array('field:'.$modelDataset.':temperature',$secondLinks,true),'Download listings must not scan and include unrelated cycles.');
check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'raw:'.$modelDataset])==='fixture raw model NPZ','Older-cycle downloads must still resolve directly.');
$planetResults=reply_data(request_api(['api'=>'results','id'=>$planetId]));check($planetResults['ok']===true,'Planet results route must resolve.');$planetLinks=array_column($planetResults['data']['downloads'],'id');
foreach(['terrain-recipe','dataset-index:'.$planetDataset,'sra:N064_surf_0129.sra','raw:'.$planetDataset.':surface-'.$token.'.npz','raw-metadata:'.$planetDataset.':surface-'.$token] as $link)check(in_array($link,$planetLinks,true),'Planet results must list '.$link.'.');
check(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'sra:N064_surf_0129.sra'])==='fixture topography SRA','Native SRA download must work.');
check(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'raw:'.$planetDataset.':surface-'.$token.'.npz'])==='fixture terrain NPZ','Raw terrain preview download must work.');
check(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'raw-metadata:'.$planetDataset.':surface-'.$token])==='fixture terrain metadata','Raw terrain metadata download must work.');
check(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'dataset-index:'.$planetDataset])===file_get_contents($planetData.'/index.json'),'Planet dataset index download must work.');
check(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'terrain-recipe'])==='fixture terrain recipe','Terrain recipe download must work.');
$oversized=reply_data(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'sra:N064_surf_0172.sra']));check(empty($oversized['ok'])&&str_contains($oversized['errors']['artifact']??'','size limit'),'Oversized artifacts must be rejected.');
echo "PHP query-route artifact tests passed\n";
} finally {
putenv('EXOPLASIM_CONFIG');putenv('EXOPLASIM_API_TEST_CHILD');putenv('EXOPLASIM_API_TEST_QUERY');
$it=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmp,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST);
foreach($it as $file){$file->isDir()?rmdir($file->getPathname()):unlink($file->getPathname());}
rmdir($tmp);
}