feat: единая админка runtime-конфигов и indpic (#22)
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s

This commit is contained in:
Panfilov Konstantin committed 2026-10-25 11:33:56 +03:00
1 parent 5b6b2d2053
commit 9515c1c62b
52 files changed
+2459 -34

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend.php';
$port=random_int(19000,29000);$env=getenv();$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='test-admin';$env['ADMIN_PASSWORD_HASH']=password_hash('fixture-admin-password',PASSWORD_DEFAULT);$env['ADMIN_STATE_DIR']=$root.'/state';$env['ADMIN_LOCAL_ROOTS']=$root.'/site';
$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);if(!is_resource($server))throw new RuntimeException('server failed');
$cookie='';
function request(string $path,string $method='GET',?array $body=null,?string $csrf=null): array {
global $port,$cookie;
$headers=['Content-Type: application/json'];if($cookie)$headers[]='Cookie: '.$cookie;if($csrf!==null)$headers[]='X-CSRF-Token: '.$csrf;
$context=stream_context_create(['http'=>['method'=>$method,'header'=>implode("\r\n",$headers),'content'=>$body===null?'':json_encode($body,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>30]]);
$result=file_get_contents('http://127.0.0.1:'.$port.$path,false,$context);$responseHeaders=$http_response_header??[];preg_match('~HTTP/\S+ (\d+)~',$responseHeaders[0]??'',$match);
foreach($responseHeaders as $header)if(str_starts_with(strtolower($header),'set-cookie: '))$cookie=explode(';',substr($header,12))[0];
return [(int)($match[1]??0),json_decode((string)$result,true,64,JSON_THROW_ON_ERROR),$responseHeaders];
}
try{
$deadline=microtime(true)+5;do{$socket=@fsockopen('127.0.0.1',$port);if($socket){fclose($socket);break;}usleep(50000);}while(microtime(true)<$deadline);
[$status,$body]=request('/api/state');check($status===401&&!$body['ok'],'HTTP unauthenticated state blocked');
[$status,$body,$headers]=request('/api/login','POST',['username'=>'test-admin','password'=>'fixture-admin-password']);check($status===200&&isset($body['csrf']),'HTTP login creates authenticated CSRF session');$csrf=$body['csrf'];
check((bool)array_filter($headers,static fn($h)=>str_contains(strtolower($h),'httponly')&&str_contains(strtolower($h),'samesite=strict')),'session cookie HttpOnly and SameSite Strict');
[$status,$body]=request('/api/state');check($status===200&&isset($body['targets'][0])&&!array_key_exists('secret',$body['targets'][0]),'HTTP state redacts transport credentials');
[$status,$body]=request('/api/apply','POST',['targets'=>[],'changes'=>[]]);check($status===403,'HTTP mutation rejects missing CSRF');
[$status,$body]=request('/api/config?target=test');check($status===200&&isset($body['version'],$body['config']),'HTTP config reads actual target version');$version=$body['version'];
[$status,$body]=request('/api/apply','POST',['targets'=>[['id'=>'test','version'=>$version]],'changes'=>[['path'=>['host'],'value'=>'http.example']]],$csrf);check($status===202&&isset($body['job']['id']),'HTTP apply queues persistent job');$id=$body['job']['id'];
(new PrelandAdmin\Jobs($store))->run($id);
[$status,$body]=request('/api/jobs?id='.$id);check($body['job']['results'][0]['status']==='success'&&$body['job']['changes'][0]['path']===['host'],'HTTP persisted result and changed paths visible');
[$status,$body]=request('/api/logout','POST',null,$csrf);check($status===200,'HTTP logout succeeds');
[$status,$body]=request('/api/state');check($status===401,'HTTP logout invalidates session');
echo "HTTP integration complete\n";
}finally{proc_terminate($server);proc_close($server);fclose($log);}
+11
View File
@@ -0,0 +1,11 @@
#define _GNU_SOURCE
#include <dlfcn.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
int rename(const char *oldpath, const char *newpath) {
int (*original)(const char *, const char *) = dlsym(RTLD_NEXT, "rename");
int result = original(oldpath, newpath);
if (result == 0 && strstr(newpath, "/.preland-admin-staging/backup-1-") != NULL) kill(getpid(), SIGKILL);
return result;
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
if(($argv[1]??'')==='--crash-child') {require dirname(__DIR__).'/bootstrap.php';$p=json_decode(file_get_contents($argv[2]),true,64,JSON_THROW_ON_ERROR);PrelandAdmin\Remote::execute($p);exit;}
require __DIR__.'/backend-security.php';
use PrelandAdmin\{Config,Catalog,Sets,Conflict};
require dirname(__DIR__,2).'/components/utm-codec.php';
$resolverConfig=['utm'=>['indpic'=>['default'=>'2','weights'=>['1'=>0,'2'=>0]]]];
check(prelandResolveUtm(['indpic'=>'abcd2'],$resolverConfig)['digits']['indpic']==='2','configured zero-weight indpic remains selectable explicitly by actual resolver');
check(prelandResolveUtm(['indpic'=>'abcd0'],$resolverConfig)['digits']['indpic']==='1','actual generic resolver zero-sum selects first configured variant');
$legacy=Config::decode('{"host":"zoomzilla.ru","utm":{"registration":{"default":"before","weights":{"before":1,"after":0},"extra":true}}}');
$changed=Config::patch($legacy,[['path'=>['utm','reg','default'],'value'=>'2']]);check($changed->utm->registration->default==='after'&&!isset($changed->utm->reg)&&$changed->utm->registration->extra===true,'canonical default adapts to legacy target without duplicate alias');
$changed=Config::patch($legacy,[['path'=>['utm','reg','weights'],'value'=>(object)['1'=>0,'2'=>2]]]);check($changed->utm->registration->weights->after===2,'numeric weight group adapts to legacy representation');
$modern=Config::decode('{"host":"zoomzilla.ru","utm":{"reg":{"default":"1","weights":{"1":1,"2":0}}}}');$changed=Config::patch($modern,[['path'=>['utm','registration','default'],'value'=>'after']]);check($changed->utm->reg->default==='2'&&!isset($changed->utm->registration),'legacy default adapts to modern target');
$changed=Config::patch($modern,[['path'=>['utm','registration','weights','before'],'value'=>5]]);check($changed->utm->reg->weights->{'1'}===5,'legacy weight path adapts to numeric target');
rejects(fn()=>Config::patch(Config::decode('{"utm":{"reg":{},"registration":{}}}'),[['path'=>['utm','reg','default'],'value'=>'2']]),'pre-existing two aliases rejected before adapter');
rejects(fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":1,"9":0}}}}'),static fn($n)=>$n===1),'zero-weight indpic variant requires complete physical set');
check(Config::validate(Config::decode('{"utm":{"theme":{"default":"1","weights":{}}}}'),static fn()=>true)===[],'enum default does not invent weights-membership restriction');
$catalog=['host'=>'https://ZOOMZILLA.RU:443','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[['number'=>1,'name'=>'one']],'tp'=>[['number'=>1,'name'=>'one']],'cons'=>[['number'=>1,'name'=>'one']]]];$store->write('catalog.json',$catalog);$cfg=Config::decode('{"host":"zoomzilla.ru","utm":{"degr":{"default":"1","weights":{"1":1}}}}');$fresh=Catalog::read($store,$cfg);check($fresh!==null&&Config::validate($cfg,static fn()=>true,$fresh)===[],'fresh matching catalog validates actual available number without fallback warning');
$invalid=Config::decode('{"host":"zoomzilla.ru","utm":{"degr":{"default":"9","weights":{"9":1}}}}');rejects(fn()=>Config::validate($invalid,static fn()=>true,$fresh),'fresh catalog rejects unknown number');$catalog['generated_at']=gmdate(DATE_ATOM,time()-181);$store->write('catalog.json',$catalog);check(Catalog::read($store,$cfg)===null&&count(Config::validate($invalid,static fn()=>true,null))===1,'stale catalog falls back to explicit unverified warning');
$validPrepared=$sets->prepareZip($zipPath,1);$source=$sets->manifest($target,1);$desired=Sets::preparedManifest($validPrepared,1);
$original=file_get_contents($root.'/site/1/01_desk_1-source.jpg');$img=new Imagick();$img->newImage(1696,920,'#aabbcc');$img->setImageFormat('jpg');file_put_contents($root.'/site/1/01_desk_1-source.jpg',$img->getImagesBlob());$img->clear();$foreign=$sets->manifest($target,1);
rejects(fn()=>$sets->upload($target,1,$validPrepared,'CAS',$source,true),'set CAS rejects third-party manifest changed after read');check($sets->manifest($target,1)===$foreign,'set CAS preserves third-party images');
$unknown=$jobs->create('upload',['targets'=>['test'],'number'=>1,'prepared'=>$validPrepared,'name'=>'Retry']);$r=$store->read('jobs/'.$unknown['id'].'.json');$r['status']='completed';$r['results'][0]=['target'=>'test','status'=>'unknown','message'=>'fixture','source_manifest'=>$source,'desired_manifest'=>$desired];$store->write('jobs/'.$unknown['id'].'.json',$r);$jobs->retry($unknown['id'],['test']);$jobs->run($unknown['id']);check($jobs->get($unknown['id'])['results'][0]['status']==='conflict'&&$sets->manifest($target,1)===$foreign,'unknown upload retry detects third-party publication and never overwrites it');
$sets->upload($target,1,$validPrepared,'CAS',$foreign,true);$unknown2=$jobs->create('upload',['targets'=>['test'],'number'=>1,'prepared'=>$validPrepared,'name'=>'Retry']);$r=$store->read('jobs/'.$unknown2['id'].'.json');$r['status']='completed';$r['results'][0]=['target'=>'test','status'=>'unknown','message'=>'fixture','source_manifest'=>$foreign,'desired_manifest'=>$desired];$store->write('jobs/'.$unknown2['id'].'.json',$r);$before=count(glob($store->dir.'/local-*/backup-1-*')?:[]);$jobs->retry($unknown2['id'],['test']);$jobs->run($unknown2['id']);check($jobs->get($unknown2['id'])['results'][0]['status']==='success'&&count(glob($store->dir.'/local-*/backup-1-*')?:[])===$before,'already-published desired upload reconciles without another swap or backup');
// SIGKILL в реальном libc rename, после переноса old→backup, без production test hooks.
$library=$root.'/kill-rename.so';$compile=proc_open(['gcc','-shared','-fPIC','-o',$library,__DIR__.'/backend-kill-rename.c','-ldl'],[0=>['pipe','r'],1=>STDOUT,2=>STDERR],$pipes);fclose($pipes[0]);check(proc_close($compile)===0,'fault injector builds inside Docker');
$files=[];foreach(array_keys(PrelandAdmin\IndpicSetStorage::expectedDimensions()) as $name)$files[$name]=['data'=>base64_encode(file_get_contents($validPrepared.'/1/'.$name)),'sha256'=>hash_file('sha256',$validPrepared.'/1/'.$name)];$private=$store->dir.'/local-'.hash('sha256',$target['root']);$request=['op'=>'set-write','root'=>$target['root'],'local_private'=>$private,'number'=>1,'files'=>$files,'expected_manifest'=>$sets->manifest($target,1)];$input=$root.'/swap.json';file_put_contents($input,json_encode($request,JSON_THROW_ON_ERROR));$env=getenv();$env['LD_PRELOAD']=$library;$child=proc_open([PHP_BINARY,__FILE__,'--crash-child',$input],[0=>['pipe','r'],1=>STDOUT,2=>STDERR],$pipes,null,$env);fclose($pipes[0]);$exit=proc_close($child);check($exit!==0&&!is_dir($target['root'].'/1')&&is_file($private.'/swap-1.json'),'SIGKILL after first rename leaves durable recoverable journal');
$crashed=$jobs->create('upload',['targets'=>['test'],'number'=>1,'prepared'=>$validPrepared,'name'=>'Crash recovery']);$record=$store->read('jobs/'.$crashed['id'].'.json');$record['status']='running';$record['results'][0]['status']='running';$record['results'][0]['desired_manifest']=$desired;$store->write('jobs/'.$crashed['id'].'.json',$record);
$jobs->interrupted($crashed['id']);clearstatcache();check(is_dir($target['root'].'/1')&&!is_file($private.'/swap-1.json'),'supervisor interruption automatically restores active folder after SIGKILL without user read');
check($jobs->get($crashed['id'])['results'][0]['status']==='unknown'&&$sets->manifest($target,1)===$desired,'automatic journal recovery preserves complete set and unknown operation status');
$unavailable=$jobs->create('upload',['targets'=>['missing'],'number'=>1,'prepared'=>$validPrepared]);$record=$store->read('jobs/'.$unavailable['id'].'.json');$record['status']='running';$record['results'][0]['status']='running';$store->write('jobs/'.$unavailable['id'].'.json',$record);$jobs->interrupted($unavailable['id']);$result=$jobs->get($unavailable['id'])['results'][0];check($result['status']==='unknown'&&($result['recovery_checked']??null)===false,'failed automatic recovery remains explicitly unconfirmed and unknown');
echo "Review regression complete\n";
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend.php';
use PrelandAdmin\{Config,Jobs,Sets,IndpicSetStorage,Auth,Problem};
function rejects(callable $call,string $message): void {try{$call();}catch(\Throwable $e){check(true,$message);return;}throw new RuntimeException('Accepted invalid input: '.$message);}
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"reg":{"default":"1","weights":{"1":1}},"registration":{"default":"before","weights":{"before":1}}}}'),static fn()=>true),'old/new UTM alias conflict rejected');
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"theme":{"default":"3","weights":{}}}}'),static fn()=>true),'invalid enum rejected');
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"degr":{"default":"1","weights":{"1":-1}}}}'),static fn()=>true),'negative UTM weight rejected');
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":0}}}}'),static fn()=>false),'incomplete default rejected');
check(Config::validate(Config::decode('{"utm":{"theme":{"default":"dark","weights":{"dark":0,"light":0}}}}'),static fn()=>true)===[],'legacy semantic enum remains valid without rewriting');
$current=$transport->readConfig();$job=$jobs->create('apply',['targets'=>[['id'=>'test','version'=>$current['version']]],'changes'=>[['path'=>['custom','empty'],'value'=>(object)[]]]]);$jobs->run($job['id']);
check($transport->readConfig()['config']->custom->empty instanceof stdClass,'empty JSON object survives persisted job');
$state=$transport->readConfig();$id=$jobs->create('apply',['targets'=>[['id'=>'test','version'=>$state['version']]],'changes'=>[['path'=>['host'],'value'=>'resumed.example']]])['id'];
$record=$store->read('jobs/'.$id.'.json');$record['status']='running';$record['results'][0]['status']='running';$desired=Config::encode(Config::patch($state['config'],[['path'=>['host'],'value'=>'resumed.example']]));$record['results'][0]['desired_hash']=hash('sha256',$desired);$store->write('jobs/'.$id.'.json',$record);$transport->writeConfig($state['version'],$desired);(new Jobs($store))->run($id);
check($jobs->get($id)['results'][0]['status']==='success','restart after write reconciles desired hash without overwrite');
$race=$transport->readConfig();$processes=[];foreach(['race-a.example','race-b.example'] as $host){$pid=pcntl_fork();if($pid===0){try{$transport->writeConfig($race['version'],Config::encode(Config::patch($race['config'],[['path'=>['host'],'value'=>$host]])));exit(0);}catch(PrelandAdmin\Conflict){exit(7);}}$processes[]=$pid;}$codes=[];foreach($processes as $pid){pcntl_waitpid($pid,$status);$codes[]=pcntl_wexitstatus($status);}sort($codes);check($codes===[0,7],'two simultaneous writes produce one success and one conflict');
$sets=new Sets($store);$zipPath=$root.'/bad.zip';$zip=new ZipArchive();$zip->open($zipPath,ZipArchive::CREATE);$zip->addFromString('1/../../evil','bad');$zip->close();rejects(fn()=>$sets->prepareZip($zipPath,1),'ZIP traversal rejected');
$zipPath=$root.'/valid.zip';$zip=new ZipArchive();$zip->open($zipPath,ZipArchive::CREATE);$cache=[];
foreach(IndpicSetStorage::expectedDimensions() as $name=>$dimension){$ext=pathinfo($name,PATHINFO_EXTENSION);$key=implode('x',$dimension).'.'.$ext;if(!isset($cache[$key])){$image=new Imagick();$image->newImage($dimension['width'],$dimension['height'],'#345678');$image->setImageFormat($ext);$cache[$key]=$image->getImagesBlob();$image->clear();}$zip->addFromString('1/'.$name,$cache[$key]);}$zip->close();
$prepared=$sets->prepareZip($zipPath,1);check(is_dir($prepared.'/1'),'90 images with CPanel sizes accepted');
$missing=$root.'/missing.zip';copy($zipPath,$missing);$z=new ZipArchive();$z->open($missing);$z->deleteName('1/01_desk_1-source.jpg');$z->close();rejects(fn()=>$sets->prepareZip($missing,1),'ZIP missing required image rejected');
$extra=$root.'/extra.zip';copy($zipPath,$extra);$z=new ZipArchive();$z->open($extra);$z->addFromString('1/extra.jpg','bad');$z->close();rejects(fn()=>$sets->prepareZip($extra,1),'ZIP extra image rejected');
$wrong=$root.'/wrong-size.zip';copy($zipPath,$wrong);$z=new ZipArchive();$z->open($wrong);$image=new Imagick();$image->newImage(10,10,'red');$image->setImageFormat('jpg');$z->addFromString('1/01_desk_1-source.jpg',$image->getImagesBlob());$image->clear();$z->close();rejects(fn()=>$sets->prepareZip($wrong,1),'ZIP incorrect dimensions rejected');
$oversize=$root.'/oversize.zip';$sparse=fopen($oversize,'w');ftruncate($sparse,67108865);fclose($sparse);rejects(fn()=>$sets->prepareZip($oversize,1),'ZIP compressed upload limit enforced before extraction');
function rawZip(array $entries): string {$local='';$central='';foreach($entries as [$name,$data,$declared]){$offset=strlen($local);$size=strlen($data);$crc=crc32($data);$local.=pack('VvvvvvVVVvv',0x04034b50,20,0,0,0,0,$crc,$size,$declared,strlen($name),0).$name.$data;$central.=pack('VvvvvvvVVVvvvvvVV',0x02014b50,20,20,0,0,0,0,$crc,$size,$declared,strlen($name),0,0,0,0,0,$offset).$name;}return $local.$central.pack('VvvvvVVv',0x06054b50,0,0,count($entries),count($entries),strlen($central),strlen($local),0);}
$duplicate=$root.'/duplicate.zip';file_put_contents($duplicate,rawZip([['1/01_desk_1-source.jpg','a',1],['1/01_desk_1-source.jpg','b',1]]));rejects(fn()=>$sets->prepareZip($duplicate,1),'duplicate ZIP entry rejected');
$inflated=$root.'/inflated.zip';file_put_contents($inflated,rawZip([['1/01_desk_1-source.jpg','a',8388609]]));rejects(fn()=>$sets->prepareZip($inflated,1),'declared ZIP expansion limit rejected before image decoding');
$result=$sets->upload($target,1,$prepared,'Тестовый набор');check(str_contains($result['message'],'90'),'local set publication revalidates delivered files');
$sets->check($target,1);check(is_file($root.'/site/1/01_desk_1-source.jpg'),'set directory published');
check((fileperms($root.'/site/1')&0777)===0755,'published set directory is readable by independent web-server UID');
rejects(fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"2","weights":{"1":1}}}}'),static fn()=>true),'default without weight variant rejected instead of runtime fallback');
$kept=hash_file('sha256',$root.'/site/1/01_desk_1-source.jpg');$badFiles=[];foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename)$badFiles[$filename]=['data'=>base64_encode(file_get_contents($prepared.'/1/'.$filename)),'sha256'=>str_repeat('0',64)];
rejects(fn()=>$transport->call(['op'=>'set-write','number'=>1,'files'=>$badFiles,'expected_manifest'=>$sets->manifest($target,1)]),'delivery hash mismatch rejects activation');check(hash_file('sha256',$root.'/site/1/01_desk_1-source.jpg')===$kept,'failed set publication preserves previous working image');
// Настоящий формат должен совпадать с расширением, даже если размеры верны.
$jpg=$prepared.'/1/01_desk_1-source.jpg';$image=new Imagick();$image->newImage(1696,920,'red');$image->setImageFormat('png');file_put_contents($jpg,$image->getImagesBlob());$image->clear();
rejects(fn()=>(new IndpicSetStorage($prepared))->validateDirectory(1),'extension and decoded image format mismatch rejected');
echo "Security contract complete\n";
+7
View File
@@ -0,0 +1,7 @@
FROM zoomzilla-api:issue328-591a67c
USER root
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server && rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --shell /bin/bash fixture && echo 'fixture:fixture-only-password' | chpasswd \
&& mkdir -p /run/sshd /home/fixture/site && chown -R fixture:fixture /home/fixture/site
ENTRYPOINT ["/bin/sh", "-c"]
CMD ["ssh-keygen -A >/dev/null 2>&1; /usr/sbin/sshd -p 2222; php /work/admin/tests/backend-sftp.php"]
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend-security.php';
use PrelandAdmin\{Store,Transport,Config,Sets};
$site='/home/fixture/site';file_put_contents($site.'/config.json',Config::encode((object)['host'=>'example.org','utm'=>(object)[]]));chown($site.'/config.json','fixture');
$ssh=new phpseclib3\Net\SSH2('127.0.0.1',2222);$key=$ssh->getServerPublicHostKey();check(is_string($key),'real SSH host key available');
$sftpTarget=$store->saveTarget(['id'=>'sftp','name'=>'SFTP fixture','domain'=>'fixture.invalid','type'=>'sftp','host'=>'127.0.0.1','port'=>2222,'user'=>'fixture','host_key'=>$key,'root'=>$site,'enabled'=>true],'fixture-only-password');
$store->verifyTarget('sftp');$sftpTarget=$store->target('sftp');$remote=new Transport($store,$sftpTarget);
$remote->test();check(true,'real SSH remote lock and permission preflight');$remoteRead=$remote->readConfig();check($remoteRead['config']->host==='example.org','real SSH config read');
$remote->writeConfig($remoteRead['version'],Config::encode((object)['host'=>'ssh-written.example','utm'=>(object)[]]));check($remote->readConfig()['config']->host==='ssh-written.example','real SSH atomic CAS and reread');
rejects(fn()=>$remote->writeConfig($remoteRead['version'],Config::encode((object)['host'=>'stale.example'])),'real remote stale version rejected');
$bad=$sftpTarget;$bad['host_key']='SHA256:'.str_repeat('A',43);rejects(fn()=>(new Transport($store,$bad))->test(),'pinned SSH key mismatch rejected before login');
// Восстановить проверенный набор после намеренной порчи формата security-тестом.
$preparedRemote=$sets->prepareZip($zipPath,1);$sets->upload($sftpTarget,1,$preparedRemote,'SFTP проверка');check(is_file($site.'/1/01_desk_1-source.jpg'),'real SFTP image transfer, remote activation, checksum and decode');
// Реальный обрыв ожидания после удалённой записи: fixture задерживает завершение команды.
$wrapper=<<<'SH'
#!/bin/sh
/usr/bin/php "$@"
if [ -f /home/fixture/delay-next-write ]; then
op=$(/usr/bin/php -r '$p=json_decode(base64_decode($argv[1]),true);echo $p["op"]??"";' "$5")
if [ "$op" = write ]; then rm /home/fixture/delay-next-write; sleep 3; fi
fi
SH;
file_put_contents('/usr/local/bin/php',$wrapper);chmod('/usr/local/bin/php',0755);
$beforeTimeout=$remote->readConfig();$timeoutJob=$jobs->create('apply',['targets'=>[['id'=>'sftp','version'=>$beforeTimeout['version']]],'changes'=>[['path'=>['host'],'value'=>'timeout-written.example']]]);
file_put_contents('/home/fixture/delay-next-write','1');chown('/home/fixture/delay-next-write','fixture');putenv('ADMIN_TRANSPORT_TIMEOUT=1');$jobs->run($timeoutJob['id']);
check($jobs->get($timeoutJob['id'])['results'][0]['status']==='unknown','real SSH timeout after write persists unknown result');
putenv('ADMIN_TRANSPORT_TIMEOUT=60');$jobs->retry($timeoutJob['id'],['sftp']);$jobs->run($timeoutJob['id']);$retried=$jobs->get($timeoutJob['id']);check($retried['results'][0]['status']==='success','retry reads committed hash after real transport timeout');
echo "SFTP integration complete\n";
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend-security.php';
// Рабочий набор уже опубликован security fixture, сохраняем отдельный свежий subprocess на job.
$env=getenv();$env['ADMIN_STATE_DIR']=$store->dir;$env['ADMIN_LOCAL_ROOTS']=$root.'/site';
$queued1=$jobs->create('check',['targets'=>['test'],'number'=>1]);$queued2=$jobs->create('check',['targets'=>['test'],'number'=>1]);
$onceLog=fopen($root.'/once.log','a');$once=proc_open([PHP_BINARY,dirname(__DIR__).'/bin/worker.php','--once'],[0=>['pipe','r'],1=>$onceLog,2=>$onceLog],$oncePipes,null,$env);fclose($oncePipes[0]);check(proc_close($once)===0,'worker once exits successfully');fclose($onceLog);
$statuses=[$jobs->get($queued1['id'])['status'],$jobs->get($queued2['id'])['status']];sort($statuses);check($statuses===['completed','queued'],'worker once processes exactly one queued job');
$record=$store->read('jobs/'.$queued1['id'].'.json');$record['status']='running';$record['results'][0]['status']='running';$record['results'][0]['desired_hash']='preserved-reconciliation-marker';$store->write('jobs/'.$queued1['id'].'.json',$record);$jobs->interrupted($queued1['id']);$interrupted=$store->read('jobs/'.$queued1['id'].'.json');check($interrupted['results'][0]['status']==='unknown'&&$interrupted['results'][0]['desired_hash']==='preserved-reconciliation-marker','crashed child becomes unknown and retains reconciliation state');
$log=fopen($root.'/worker.log','a');$started=microtime(true);$process=proc_open([PHP_BINARY,dirname(__DIR__).'/bin/worker.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);
function awaitJob(string $id,int $seconds=25): array {global $jobs;$until=microtime(true)+$seconds;do{$job=$jobs->get($id);if($job['status']==='completed')return $job;usleep(100000);}while(microtime(true)<$until);throw new RuntimeException('Worker job timeout');}
try{
$first=$jobs->create('check',['targets'=>['test'],'number'=>1]);$one=awaitJob($first['id']);check($one['results'][0]['status']==='success','worker subprocess validates first 90-image job');
while(microtime(true)-$started<65)sleep(1);
$second=$jobs->create('check',['targets'=>['test'],'number'=>1]);$two=awaitJob($second['id']);check($two['results'][0]['status']==='success','worker still validates 90 images after process wall time exceeds 60 seconds');
$status=proc_get_status($process);check($status['running'],'supervisor remains alive beyond Imagick time policy');
echo "Worker isolation complete\n";
}finally{proc_terminate($process);proc_close($process);fclose($log);}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/bootstrap.php';
use PrelandAdmin\Config;
use PrelandAdmin\Store;
use PrelandAdmin\Transport;
use PrelandAdmin\Jobs;
function check(bool $value, string $message): void { if (!$value) throw new RuntimeException($message); echo "PASS $message\n"; }
$root = sys_get_temp_dir() . '/preland-admin-' . bin2hex(random_bytes(6));
mkdir($root, 0700, true); mkdir($root . '/site',0700); mkdir($root . '/state',0700);
putenv('ADMIN_STATE_DIR=' . $root . '/state'); putenv('ADMIN_LOCAL_ROOTS=' . $root . '/site');
$initial = (object)['host'=>'zoomzilla.ru','requisites'=>['one'],'custom'=>(object)['flag'=>true,'zero'=>0,'empty'=>(object)[]],'utm'=>(object)[]];
file_put_contents($root . '/site/config.json', json_encode($initial));
$store = new Store($root . '/state');
$target = $store->saveTarget(['id'=>'test','name'=>'Тест','domain'=>'test.invalid','environment'=>'stage','type'=>'local','root'=>$root . '/site','enabled'=>true]);
$store->verifyTarget('test');$target=$store->target('test');
$transport = new Transport($store, $target);
$before = $transport->readConfig();
$next = Config::patch($before['config'], [['path'=>['host'],'value'=>'example.org']]);
check($next->custom->flag === true && $next->custom->zero === 0 && $next->custom->empty instanceof stdClass, 'selected patch preserves unrelated values and types');
$transport->writeConfig($before['version'], Config::encode($next));
$after = $transport->readConfig(); check($after['config']->host === 'example.org','atomic save and reread');
try { $transport->writeConfig($before['version'], Config::encode($initial)); throw new RuntimeException('missed conflict'); } catch (PrelandAdmin\Conflict $e) { check(true,'stale version conflicts'); }
check(count($store->backups('test')) === 1,'private backup saved');
$jobs = new Jobs($store);
$job = $jobs->create('apply', ['targets'=>[['id'=>'test','version'=>$after['version']],['id'=>'missing','version'=>'x']], 'changes'=>[['path'=>['custom','flag'],'value'=>false]]]);
$jobs->run($job['id']); $job=$jobs->get($job['id']);
check($job['results'][0]['status']==='success' && $job['results'][1]['status']==='error','one target failure does not undo successful target');
$restored=$transport->readConfig();
$backup=$store->backups('test')[0];
$job=$jobs->create('restore',['target'=>'test','id'=>$backup['id'],'version'=>$restored['version']]);$jobs->run($job['id']);
check($jobs->get($job['id'])['results'][0]['status']==='success','backup restores with version protection');
try { $store->saveTarget(['id'=>'escape','type'=>'local','root'=>'/etc','domain'=>'a.invalid']); throw new RuntimeException('unsafe root accepted'); } catch (PrelandAdmin\Problem $e) {check(true,'local root confined to explicit mounts');}
try { Config::patch($initial,[['path'=>['__proto__'],'value'=>1]]); throw new RuntimeException('unsafe key accepted'); } catch(PrelandAdmin\Problem $e) {check(true,'unsafe patch path rejected');}
echo "Backend contract complete\n";
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
# Запуск только в одноразовом Docker-контейнере от root, без host state/socket.
set -eu
test "$(id -u)" = 0
test ! -e /srv/preland-admin
test ! -e /usr/bin/docker
base=/srv/preland-admin
install -d -m 700 "$base" "$base/secrets" "$base/deploy" /tmp/catalog-victim
install -d -m 700 -o 997 -g 987 "$base/state"
printf 'unchanged\n' > /tmp/catalog-victim/original
printf 'not executed\n' > "$base/deploy/export-catalog.php"
cat > /usr/bin/docker <<'SH'
#!/bin/sh
printf '%s\n' '{"host":"zoomzilla.ru","generated_at":"2026-10-25T00:00:00Z","groups":{"degr":[],"tp":[],"cons":[]}}'
SH
chmod 755 /usr/bin/docker
# Унаследованный cwd моделирует доступ к корню bind mount внутри приложения,
# минуя закрытый host-parent /srv/preland-admin.
(cd "$base/state" && setpriv --reuid=997 --regid=987 --clear-groups ln -s /tmp/catalog-victim catalog.json)
sed 's/\r$//' /work/admin/deploy/refresh-catalog.sh > /tmp/catalog-refresh.sh
bash /tmp/catalog-refresh.sh
test ! -L "$base/state/catalog.json"
test -f "$base/state/catalog.json"
test "$(stat -c %u /tmp/catalog-victim/original)" = 0
test "$(cat /tmp/catalog-victim/original)" = unchanged
test "$(find /tmp/catalog-victim -type f | wc -l)" = 1
test "$(stat -c '%u:%g:%a' "$base/state/catalog.json")" = 997:987:600
if setpriv --reuid=997 --regid=987 --clear-groups ls "$base/secrets" >/dev/null 2>&1; then
echo 'FAIL: app UID can inspect root preparation directory' >&2
exit 1
fi
echo 'PASS root catalog publisher replaces destination symlink without following it; victim and owner intact; preparation inaccessible to app UID'
+76
View File
@@ -0,0 +1,76 @@
/* Real HTTP + rendered regression: stale version, independent partial result,
* invalid ZIP recovery, safe DOM. Run only against disposable Docker fixtures.
* No browser network mocking. Browser plugin not available. */
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
const fs=require('node:fs');
(async()=>{
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});
const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});
const errors=[],consoleMessages=[];page.on('pageerror',e=>errors.push(e.message));page.on('console',m=>{if(['error','warning'].includes(m.type()))consoleMessages.push({type:m.type(),text:m.text()});});page.on('dialog',d=>d.accept());
await page.goto(process.env.ADMIN_URL||'http://admin');
await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');
await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');
await page.getByRole('button',{name:'Войти',exact:true}).click();
await page.locator('#workspace').waitFor({state:'visible'});
const state=await page.request.get('/api/state').then(r=>r.json());
const api=async(path,data)=>{const response=await page.request.post('/api/'+path,{data,headers:{'X-CSRF-Token':state.csrf}});const result=await response.json();assert.equal(result.ok,true,result.error);return result;};
const read=async(id)=>page.request.get('/api/config?target='+encodeURIComponent(id)).then(r=>r.json());
const poll=async(id)=>{for(let n=0;n<120;n++){const r=await page.request.get('/api/jobs?id='+id).then(r=>r.json());assert.ok(r.ok,r.error||'GET jobs вернул ошибку без описания');assert.ok(r.job,'GET jobs не вернул задание '+id);if(r.job.status==='completed')return r.job;await new Promise(r=>setTimeout(r,250));}throw Error('job timeout '+id);};
const ids=state.targets.filter(t=>t.enabled).slice(0,2).map(t=>t.id);assert.equal(ids.length,2,'two disposable targets required');
const originals=await Promise.all(ids.map(read));
const externalHost='external-'+Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,10)+'.example.org';
let primaryError=null;
const restore=async()=>{const targets=await Promise.all(ids.map(async id=>({id,version:(await read(id)).version})));for(let i=0;i<ids.length;i++){const r=await api('apply',{targets:[targets[i]],changes:[{path:['host'],value:originals[i].config.host}]});const job=await poll(r.job.id);assert.equal(job.results[0].status,'success','fixture host restored');}};
try{
await page.locator('#source-target').selectOption(ids[0]);
await page.getByLabel('Основной сайт',{exact:true}).waitFor();
await page.locator('#targets input[value="'+ids[1]+'"]').check();
await page.waitForFunction(()=>!document.querySelector('#targets').textContent.includes('чтение версии'));
const altered=await api('apply',{targets:[{id:ids[1],version:originals[1].version}],changes:[{path:['host'],value:externalHost}]});
assert.equal((await poll(altered.job.id)).results[0].status,'success');
await page.getByLabel('Основной сайт',{exact:true}).fill('partial-check.example.org');
const responsePromise=page.waitForResponse(r=>r.url().endsWith('/api/apply')&&r.request().method()==='POST');
await page.getByRole('button',{name:'Применить к выбранным',exact:true}).click();
const submitted=await (await responsePromise).json();
const result=await poll(submitted.job.id);
assert.deepEqual(result.results.map(r=>r.status),['success','conflict']);
const job=page.locator('.job').filter({hasText:submitted.job.id});
await job.getByText('Конфликт версии — перечитайте домен',{exact:true}).waitFor();
assert.equal((await read(ids[0])).config.host,'partial-check.example.org');
assert.equal((await read(ids[1])).config.host,externalHost,'stale UI did not overwrite external writer');
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/admin-conflict-partial.png',fullPage:true});
await page.reload();
await page.locator('#workspace').waitFor({state:'visible'});
await page.locator('.job').filter({hasText:submitted.job.id}).waitFor();
await page.locator('#source-target').selectOption(ids[0]);
await page.getByLabel('Основной сайт',{exact:true}).waitFor();
await page.locator('#set-add input[name=number]').fill(process.env.ZIP_NUMBER||'9');
await page.locator('#set-add input[name=name]').fill('Тест ZIP');
await page.locator('#set-add input[type=file]').setInputFiles({name:'invalid.zip',mimeType:'application/zip',buffer:Buffer.from('not a ZIP')});
const uploadResponse=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload'));
await page.locator('#set-add button').click();
const invalidResult=await (await uploadResponse).json();
if(invalidResult.ok){const invalidJob=await poll(invalidResult.job.id);assert.equal(invalidJob.results[0].status,'error');await page.locator('#indpic-result-modal').waitFor({state:'visible'});await page.getByRole('button',{name:'Готово',exact:true}).click();}
else await page.locator('#notice.alert-danger').waitFor({state:'visible'});
assert.equal((await read(ids[0])).config.host,'partial-check.example.org','invalid ZIP kept working config');
if(process.env.VALID_ZIP){
await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP);
const response=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload'));
await page.locator('#set-add button').click();const accepted=await(await response).json();assert.equal(accepted.ok,true,accepted.error);
const done=await poll(accepted.job.id);assert.equal(done.results[0].status,'success',done.results[0].message);
await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor();
await page.getByRole('button',{name:'Готово',exact:true}).click();
}
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/admin-zip-recovery.png',fullPage:true});
fs.writeFileSync((process.env.UI_OUTPUT||'/tmp')+'/admin-regression-console.json',JSON.stringify({pageErrors:errors,console:consoleMessages},null,2));
assert.deepEqual(errors,[]);console.log('PASS real partial success/conflict/external state/persistent history/invalid ZIP'+(process.env.VALID_ZIP?'/valid ZIP recovery':''));
}catch(error){primaryError=error;}finally{
const cleanupErrors=[];
try{await restore();}catch(error){cleanupErrors.push(error);}
try{await browser.close();}catch(error){cleanupErrors.push(error);}
if(primaryError&&cleanupErrors.length)throw new AggregateError([primaryError,...cleanupErrors],'Основная ошибка browser-сценария; отдельно — ошибки восстановления fixture / закрытия browser',{cause:primaryError});
if(primaryError)throw primaryError;
if(cleanupErrors.length)throw new AggregateError(cleanupErrors,'Ошибка восстановления fixture / закрытия browser');
}
})().catch(e=>{console.error(e);process.exit(1);});
+47
View File
@@ -0,0 +1,47 @@
/* Browser plugin not available; run only inside the supplied Docker Playwright image.
* Required env: ADMIN_URL, ADMIN_USER, ADMIN_PASSWORD, UI_OUTPUT; optional SECOND_TARGET.
*/
const { chromium } = require('playwright-core');
const assert = require('node:assert/strict');
const fs = require('node:fs');
(async () => {
const browser = await chromium.launch({headless:true,executablePath:process.env.CHROMIUM_PATH || '/usr/bin/chromium',args:['--no-sandbox']});
const page = await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});
const errors=[],consoleMessages=[]; page.on('pageerror',e=>errors.push(e.message));page.on('console',m=>{if(['error','warning'].includes(m.type()))consoleMessages.push({type:m.type(),text:m.text()});});
await page.goto(process.env.ADMIN_URL || 'http://admin');
assert.match(await page.title(), /Прелендинги/);
await page.getByLabel('Логин', {exact:true}).fill(process.env.ADMIN_USER || 'admin');
await page.getByLabel('Пароль', {exact:true}).fill(process.env.ADMIN_PASSWORD || 'test-password');
await page.getByRole('button',{name:'Войти',exact:true}).click();
await page.locator('#workspace').waitFor({state:'visible'});
await page.locator('#source-target').selectOption({index:1});
await page.getByLabel('Основной сайт', {exact:true}).waitFor();
const host = page.getByLabel('Основной сайт', {exact:true});
const original = await host.inputValue();
const secondOriginal=process.env.SECOND_TARGET?await page.request.get('/api/config?target='+encodeURIComponent(process.env.SECOND_TARGET)).then(r=>r.json()).then(r=>r.config.host):null;
await host.fill('ui-check.example.org');
assert.match(await page.locator('#change-summary').innerText(), /host/);
assert.equal(await page.locator('#targets input:checked').count(),1);
if (process.env.SECOND_TARGET) await page.locator('#targets input[value="'+process.env.SECOND_TARGET+'"]').check();
const apply = async () => {
const pending=page.waitForResponse(r=>r.url().endsWith('/api/apply')&&r.request().method()==='POST');
await page.getByRole('button',{name:'Применить к выбранным',exact:true}).click();
const accepted=await(await pending).json();assert.equal(accepted.ok,true,accepted.error);
await page.locator('.job').filter({hasText:accepted.job.id}).getByText('Сохранено и проверено',{exact:true}).first().waitFor();
};
await apply();
await page.getByRole('button',{name:'Перечитать',exact:true}).click();
await page.waitForFunction(()=>document.querySelector('[aria-label="Основной сайт"]')?.value==='ui-check.example.org');
await host.fill(original);
await apply();
if(process.env.SECOND_TARGET){await page.locator('#source-target').selectOption(process.env.SECOND_TARGET);await host.waitFor();await host.fill(secondOriginal);await apply();}
assert.equal(await page.locator('textarea').count(),0,'JSON textarea absent');
assert.equal(await page.locator('.indpic-sets-table th').allTextContents().then(x=>x.join('|')),'Номер / название|Default|Проверка|ZIP|Действия');
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/admin-desktop.png',fullPage:true});
await page.setViewportSize({width:390,height:844});
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/admin-mobile.png',fullPage:true});
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true,'no page-level horizontal overflow');
fs.writeFileSync((process.env.UI_OUTPUT||'/tmp')+'/admin-console.json',JSON.stringify({pageErrors:errors,console:consoleMessages},null,2));
assert.deepEqual(errors,[]);assert.deepEqual(consoleMessages.filter(m=>!m.text.includes('status of 401')),[],'no unexpected browser console errors');
await browser.close(); console.log('PASS real login/read/edit/apply/reload/restore + mobile + console');
})().catch(e=>{console.error(e);process.exit(1);});
+85
View File
@@ -0,0 +1,85 @@
/* Real UI UTM regression. Disposable Docker fixtures a/b only.
* Browser plugin not available. No route mocks or host-installed runtimes.
* VALID_ZIP must contain set3; PUBLIC_A/B are optional fixture origins. */
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
const fs=require('node:fs');
(async()=>{
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});
const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});
const output=process.env.UI_OUTPUT||'/tmp';
const errors=[],consoleMessages=[];
page.on('pageerror',e=>errors.push(e.message));
page.on('console',m=>{if(['error','warning'].includes(m.type()))consoleMessages.push({type:m.type(),text:m.text()});});
page.on('dialog',dialog=>dialog.accept());
const read=async id=>{const r=await page.request.get('/api/config?target='+encodeURIComponent(id)).then(r=>r.json());assert.ok(r.ok,r.error);assert.ok(r.config,'config missing');return r;};
const poll=async id=>{for(let n=0;n<480;n++){const r=await page.request.get('/api/jobs?id='+id).then(r=>r.json());assert.ok(r.ok,r.error);assert.ok(r.job,'job missing: '+id);if(r.job.status==='completed')return r.job;await new Promise(resolve=>setTimeout(resolve,250));}throw Error('job timeout: '+id);};
const group=()=>page.locator('section.field-block').filter({has:page.getByRole('heading',{name:'indpic= UTM',exact:true})});
const open=async id=>{await page.locator('#source-target').selectOption(id);await page.getByLabel('indpic default',{exact:true}).waitFor();await page.waitForFunction(()=>document.querySelector('#read-state').textContent.includes('версия'));};
const chooseBoth=async()=>{await page.locator('#targets input[value="b"]').check();await page.waitForFunction(()=>!document.querySelector('#targets').textContent.includes('чтение версии'));};
const submit=async()=>{const response=page.waitForResponse(r=>r.url().endsWith('/api/apply')&&r.request().method()==='POST');await page.getByRole('button',{name:'Применить к выбранным',exact:true}).click();const accepted=await(await response).json();assert.ok(accepted.ok,accepted.error);assert.ok(accepted.job);const job=await poll(accepted.job.id);const rendered=page.locator('.job').filter({hasText:job.id});await rendered.getByText('Завершено',{exact:false}).first().waitFor();return {job,rendered};};
const setWeights=async weights=>{const section=group();const rows=section.locator('.weight-table tbody tr');while(await rows.count())await rows.last().getByRole('button',{name:'Удалить',exact:true}).click();for(const [variant,weight]of Object.entries(weights)){await section.getByRole('button',{name:'Добавить вариант',exact:true}).click();const row=rows.last();await row.getByLabel('indpic вариант',{exact:true}).selectOption(variant);await row.locator('input[type=number]').fill(String(weight));}};
let originals=[],primaryError=null;
try{
await page.goto(process.env.ADMIN_URL||'http://admin');
await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');
await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');
await page.getByRole('button',{name:'Войти',exact:true}).click();
await page.locator('#workspace').waitFor({state:'visible'});
const state=await page.request.get('/api/state').then(r=>r.json());assert.ok(state.ok,state.error);
for(const id of ['a','b'])assert.ok(state.targets.some(t=>t.id===id&&t.enabled),'enabled fixture '+id+' required');
originals=await Promise.all(['a','b'].map(read));
for(const original of originals){assert.ok(!Object.hasOwn(original.config.utm.indpic.weights,'3'),'original fixture must omit weights3 for rejection scenario');assert.notEqual(String(original.config.utm.indpic.default),'3');}
await open('a');await chooseBoth();
// Delivery is a real two-target ZIP form submission, not an API shortcut.
assert.ok(process.env.VALID_ZIP,'VALID_ZIP=/fixture/valid-set3.zip required');
await page.locator('#set-add input[name=number]').fill('3');
await page.locator('#set-add input[name=name]').fill('UI UTM — набор 3');
await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP);
const uploadResponse=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload'));
await page.locator('#set-add button').click();
const upload=await(await uploadResponse).json();assert.ok(upload.ok,upload.error);
const uploaded=await poll(upload.job.id);assert.deepEqual(uploaded.results.map(r=>r.status),['success','success']);
await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor();
await page.getByRole('button',{name:'Готово',exact:true}).click();
// Wrong path must fail clearly, without altering either runtime config.
await page.getByLabel('indpic default',{exact:true}).selectOption('3');
const rejected=await submit();assert.deepEqual(rejected.job.results.map(r=>r.status),['error','error']);
for(const result of rejected.job.results)assert.match(result.message,/Добавьте вариант 3 в таблицу весов indpic.*вес 0/);
await rejected.rendered.getByText(/Добавьте вариант 3 в таблицу весов indpic/).first().waitFor();
assert.deepEqual((await read('a')).config,originals[0].config);assert.deepEqual((await read('b')).config,originals[1].config);
await page.screenshot({path:output+'/admin-utm-rejected-default.png',fullPage:true});
// Correct path: a zero-weight row and explicit default3, only these two fields.
await group().getByRole('button',{name:'Добавить вариант',exact:true}).click();
const added=group().locator('.weight-table tbody tr').last();
await added.getByLabel('indpic вариант',{exact:true}).selectOption('3');
await added.locator('input[type=number]').fill('0');
// Rejected submission cleared explicit field selection; select default again.
await page.getByLabel('indpic default',{exact:true}).selectOption('3');
assert.equal(await page.locator('#targets input:checked').count(),2);
assert.match(await page.locator('#change-summary').innerText(),/utm.indpic.weights/);
const applied=await submit();assert.deepEqual(applied.job.results.map(r=>r.status),['success','success']);
const actuals=await Promise.all(['a','b'].map(read));
for(let i=0;i<2;i++){const expected=structuredClone(originals[i].config);expected.utm.indpic.default=typeof originals[0].config.utm.indpic.default==='number'?3:'3';expected.utm.indpic.weights={...originals[0].config.utm.indpic.weights,'3':0};assert.deepEqual(actuals[i].config,expected,'only selected indpic fields changed on '+['a','b'][i]);}
fs.writeFileSync(output+'/admin-utm-before-restore.json',JSON.stringify({job:applied.job.id,configs:{a:actuals[0].config,b:actuals[1].config}},null,2));
await page.getByRole('button',{name:'Перечитать',exact:true}).click();await page.waitForFunction(()=>document.querySelector('[aria-label="indpic default"]')?.value==='3');
assert.equal(await group().locator('.weight-table tbody tr').filter({has:page.locator('option[value="3"]:checked')}).count(),1);
await page.screenshot({path:output+'/admin-utm-applied.png',fullPage:true});
const publicEvidence=[];
for(const origin of [process.env.PUBLIC_A,process.env.PUBLIC_B].filter(Boolean)){
const response=await page.request.get(new URL('/?indpic=abcd3zzzz',origin).href);assert.equal(response.status(),200,'public HTML '+origin);const html=await response.text();assert.match(html,/data-source="[^\"]*\/3\/01_desk_1-source\.avif/,'public resolver selects uploaded set3');
const image=await page.request.get(new URL('/3/01_desk_1-source.jpg',origin).href);assert.equal(image.status(),200,'public uploaded JPEG '+origin);const bytes=await image.body();assert.equal(bytes[0],0xff);assert.equal(bytes[1],0xd8);publicEvidence.push({origin,htmlStatus:response.status(),imageStatus:image.status(),bytes:bytes.length});
}
fs.writeFileSync(output+'/admin-utm-public.json',JSON.stringify(publicEvidence,null,2));
assert.deepEqual(errors,[]);
}catch(error){primaryError=error;}finally{
const cleanupErrors=[];
// The restoration itself uses actual form row removal/addition/default controls.
for(let i=0;i<originals.length;i++)try{await open(['a','b'][i]);await setWeights(originals[i].config.utm.indpic.weights);await page.getByLabel('indpic default',{exact:true}).selectOption(String(originals[i].config.utm.indpic.default));const restored=await submit();assert.equal(restored.job.results[0].status,'success',restored.job.results[0].message);assert.deepEqual((await read(['a','b'][i])).config,originals[i].config,'UI restored full original config');}catch(error){cleanupErrors.push(error);}
fs.writeFileSync(output+'/admin-utm-console.json',JSON.stringify({pageErrors:errors,console:consoleMessages},null,2));
try{await browser.close();}catch(error){cleanupErrors.push(error);}
if(primaryError&&cleanupErrors.length)throw new AggregateError([primaryError,...cleanupErrors],'UTM scenario failed; separate UI restore errors',{cause:primaryError});
if(primaryError)throw primaryError;if(cleanupErrors.length)throw new AggregateError(cleanupErrors,'UI restore failed');
}
console.log('PASS real UI bulk ZIP3 / rejected default without weight / weights3=0+default3 to a+b / reread / preserve other fields / public origins / UI restore');
})().catch(error=>{console.error(error);process.exit(1);});