feat(admin): add runtime overview and independent saved UTM link builder
deploy-preland / deploy zoomstage.online (push) Successful in 57s

This commit is contained in:
Panfilov Konstantin committed 2026-10-25 18:17:23 +03:00
1 parent 48c71f3300
commit 2cfa2e2d2e
10 files changed
+421 -8

No files matched your search

+29
View File
@@ -2,6 +2,7 @@
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';
$env['PHP_CLI_SERVER_WORKERS']='4';
$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 {
@@ -15,6 +16,7 @@ function request(string $path,string $method='GET',?array $body=null,?string $cs
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]=request('/api/link-builder?target=test');check($status===401,'HTTP link builder requires authentication');
[$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');
@@ -23,6 +25,33 @@ try{
[$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/link-builder?target=test');check($status===200&&$body['selection']===null&&$body['url']===null&&$body['saved_at']===null,'HTTP new link builder state is explicitly empty');
$linkSelection=array_fill_keys(['indpic','theme','degr','reg','banch','tp','cons','focus'],'rnd');$runtimeHash=hash_file('sha256',$root.'/site/config.json');$jobCount=count(glob($store->dir.'/jobs/*.json'));
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection]);check($status===403,'HTTP link generation requires CSRF');
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection],$csrf);check($status===200&&$body['selection']===$linkSelection&&str_starts_with($body['url'],'https://test.invalid/?'),'HTTP explicit link generation returns saved independent choice');$savedLink=$body;
[$status,$body]=request('/api/link-builder?target=test');check($status===200&&$body===$savedLink,'HTTP reload returns exact stored URL without fresh noise');
$linkSelection['focus']='2';[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection],$csrf);check($status===200&&array_diff_assoc($body['selection'],$savedLink['selection'])===['focus'=>'2'],'HTTP changing one link selector preserves other choices');
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection+['evil'=>'1']],$csrf);check($status===422,'HTTP unexpected selection key rejected');
[$status,$body]=request('/api/link-builder','POST',['target'=>['test'],'selection'=>$linkSelection],$csrf);check($status===422,'HTTP non-string target rejected with safe validation status');
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>[]],$csrf);check($status===422,'HTTP selection must be complete object');
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection,'url'=>'javascript:alert(1)'],$csrf);check($status===422,'HTTP caller cannot inject URL or extra top-level fields');
[$status,$body]=request('/api/targets','POST',['target'=>['id'=>'test','domain'=>'renamed.invalid']],$csrf);check($status===200,'HTTP target domain rename accepted');
[$status,$body]=request('/api/link-builder?target=test');check($status===200&&str_starts_with($body['url'],'https://renamed.invalid/?')&&$body['selection']===$linkSelection,'HTTP saved selection survives domain rename with current URL base');
// Настоящий медленный SSH read: отдельный TCP fixture принимает соединение без SSH banner.
$slowPort=random_int(30000,39000);$accepted=$root.'/slow-accepted';$ready=$root.'/slow-ready';
$tcpCode='$server=stream_socket_server("tcp://127.0.0.1:".$argv[1]);if(!$server)exit(2);file_put_contents($argv[2],"ready");$client=stream_socket_accept($server,10);if($client){file_put_contents($argv[3],"accepted");sleep(3);fclose($client);}fclose($server);';
$tcp=proc_open([PHP_BINARY,'-r',$tcpCode,(string)$slowPort,$ready,$accepted],[0=>['pipe','r'],1=>$log,2=>$log],$tcpPipes);fclose($tcpPipes[0]);
$slowRead=null;
try{
$until=microtime(true)+5;while(!is_file($ready)&&microtime(true)<$until)usleep(10000);if(!is_file($ready))throw new RuntimeException('Slow TCP fixture unavailable');
$targets=$store->read('targets.json');$targets['slow']=['id'=>'slow','domain'=>'slow.invalid','type'=>'sftp','host'=>'127.0.0.1','port'=>$slowPort,'user'=>'fixture','host_key'=>'SHA256:'.str_repeat('A',43),'secret'=>'fixture','root'=>'/not-mounted','enabled'=>true];$store->write('targets.json',$targets);
$readCode='$ctx=stream_context_create(["http"=>["header"=>"Cookie: ".$argv[2],"ignore_errors"=>true,"timeout"=>10]]);@file_get_contents($argv[1],false,$ctx);';
$slowRead=proc_open([PHP_BINARY,'-r',$readCode,'http://127.0.0.1:'.$port.'/api/config?target=slow',$cookie],[0=>['pipe','r'],1=>$log,2=>$log],$readPipes);fclose($readPipes[0]);
$until=microtime(true)+5;while(!is_file($accepted)&&microtime(true)<$until)usleep(10000);if(!is_file($accepted))throw new RuntimeException('Slow authenticated config read did not reach transport');
$started=microtime(true);[$status,$body]=request('/api/link-builder?target=test');$elapsed=microtime(true)-$started;
check($status===200&&$elapsed<1.5,'slow remote config read does not block link builder in same authenticated session');
}finally{if(is_resource($slowRead))proc_close($slowRead);proc_close($tcp);}
check(hash_file('sha256',$root.'/site/config.json')===$runtimeHash&&count(glob($store->dir.'/jobs/*.json'))===$jobCount,'HTTP link generation does not change runtime or enqueue jobs');
[$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";
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/bootstrap.php';
use PrelandAdmin\{LinkBuilder, Problem, Store};
function linkCheck(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
echo "PASS {$message}\n";
}
function linkReject(callable $callback, string $message): void
{
try { $callback(); } catch (Problem) { linkCheck(true, $message); return; }
throw new RuntimeException('Приняты неверные данные: ' . $message);
}
$testRoot = sys_get_temp_dir() . '/link-builder-' . bin2hex(random_bytes(8));
$linkStore = new Store($testRoot);
// Недоступные runtime/SSH: конструктор обязан работать без их чтения.
$linkStore->write('targets.json', [
'first' => ['id'=>'first','domain'=>'first.example','type'=>'sftp','host'=>'unreachable.invalid','root'=>'/not-mounted','secret'=>'fixture-only-secret'],
'second' => ['id'=>'second','domain'=>'second.example','type'=>'local','root'=>'/not-mounted'],
]);
$runtimeFile = $testRoot . '/runtime-fixture.json';
file_put_contents($runtimeFile, '{"host":"unchanged.example","utm":{"indpic":{"default":"1"}}}');
$runtimeBefore = hash_file('sha256', $runtimeFile);
$builder = new LinkBuilder($linkStore);
$empty = ['selection'=>null, 'url'=>null, 'saved_at'=>null];
linkCheck($builder->get('first') === $empty, 'first opening has no implicit saved choice or generated URL');
$selection = ['indpic'=>'9','theme'=>'2','degr'=>'3','reg'=>'1','banch'=>'rnd','tp'=>'4','cons'=>'5','focus'=>'rnd'];
$saved = $builder->save('first', $selection);
linkCheck($saved['selection'] === $selection && is_string($saved['saved_at']), 'explicit save returns complete independent selection and timestamp');
linkCheck(str_starts_with($saved['url'], 'https://first.example/?'), 'URL uses saved target HTTPS origin');
parse_str(parse_url($saved['url'], PHP_URL_QUERY), $parameters);
linkCheck(array_keys($parameters) === array_keys($selection), 'URL contains all eight canonical UTM keys');
foreach ($selection as $key=>$value) {
$digit = $value === 'rnd' ? '0' : $value;
linkCheck((bool)preg_match('/^[a-zA-Z0-9]{4}' . $digit . '[a-zA-Z0-9]+$/D', $parameters[$key]), "{$key} has four ASCII prefix characters, selected digit and noise suffix");
}
linkCheck((new LinkBuilder(new Store($testRoot)))->get('first') === $saved, 'reload preserves exact saved URL and selection without regenerating noise');
$regenerated = $builder->save('first', $selection);
linkCheck($regenerated['selection']===$saved['selection']&&$regenerated['url']!==$saved['url'],'only explicit generation refreshes random noise for unchanged selection');
$saved = $regenerated;
linkCheck($builder->get('second') === $empty, 'other target does not inherit saved selection');
$modified = $saved['selection']; $modified['theme'] = '1';
$updated = $builder->save('first', $modified);
linkCheck($updated['selection']['theme']==='1' && array_diff_assoc($updated['selection'], $selection)===['theme'=>'1'], 'changing one selector preserves all other saved choices');
$builder->save('second', array_fill_keys(array_keys($selection), 'rnd'));
linkCheck($builder->get('first') === $updated, 'saving another target does not overwrite first target state');
$targets = $linkStore->read('targets.json');$targets['first']['domain']='renamed.example';$linkStore->write('targets.json',$targets);
$renamed = $builder->get('first');
linkCheck(str_starts_with($renamed['url'],'https://renamed.example/?') && !str_contains($renamed['url'],'first.example'), 'domain rename rebases saved URL to current origin');
linkCheck($renamed['selection']===$modified && $renamed['saved_at']===$updated['saved_at'] && parse_url($renamed['url'],PHP_URL_QUERY)===parse_url($updated['url'],PHP_URL_QUERY), 'domain rename preserves selection, timestamp and existing query noise');
foreach ([[], array_diff_key($selection,['focus'=>true]), $selection+['unexpected'=>'1'], array_replace($selection,['theme'=>'9']), array_replace($selection,['indpic'=>'0']), array_replace($selection,['cons'=>1]), array_replace($selection,['tp'=>'rnd&host=evil']), array_replace($selection,['reg'=>['1']])] as $invalid) {
linkReject(fn()=>$builder->save('first',$invalid),'strict selection rejects missing, unknown, wrong-type or injected value');
}
linkReject(fn()=>$builder->get('../targets'),'target traversal rejected');
linkReject(fn()=>$builder->get('missing'),'unknown target rejected');
foreach (['evil.example/path','user@evil.example','https://evil.example','evil.example:443','bad..example','-bad.example',"evil.example\nX: bad"] as $domain) {
$targets['first']['domain']=$domain;$linkStore->write('targets.json',$targets);
linkReject(fn()=>$builder->get('first'),'unsafe registry domain rejected instead of composing executable URL');
}
linkCheck(hash_file('sha256',$runtimeFile)===$runtimeBefore && glob($testRoot.'/jobs/*.json')===[], 'link builder leaves runtime bytes unchanged and creates no jobs');
linkCheck((fileperms($testRoot.'/link-builder-first.json') & 0777)===0600,'saved selection and URL remain in private 0600 state');
echo "Link builder contract complete\n";
+46
View File
@@ -0,0 +1,46 @@
/* Approved overview + independent link builder, preland#22/issue3767.
* Automated Docker QA; production CUA verification is owned by the lead.
* No mocked routes; constructor must never write config or create jobs. */
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=[],mutations=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());
page.on('request',r=>{if(r.method()!=='GET'&&/\/api\/(apply|restore|sets\/|jobs\/retry)/.test(r.url()))mutations.push(r.url());});
try{
await page.goto(process.env.ADMIN_URL||'http://admin');
assert.match(await page.locator('script[src*="admin.js"]').getAttribute('src'),/^\/assets\/admin[.]js[?]v=[a-f0-9]{12}$/);
assert.match(await page.locator('link[href*="admin.css"]').getAttribute('href'),/^\/assets\/admin[.]css[?]v=[a-f0-9]{12}$/);
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'});
assert.equal(await page.locator('#workspace > section').first().getAttribute('id'),'overview','saved runtime overview is first section');
await page.locator('#overview-table tbody tr').first().waitFor();assert.equal(await page.locator('#overview-table tbody tr[data-overview-utm]').count(),8);await page.locator('#overview-table tr[data-overview-extra=host]').waitFor();await page.locator('#overview-table tr[data-overview-extra=requisites]').waitFor();
const read=async id=>{const r=await page.request.get('/api/config?target='+id).then(r=>r.json());assert.ok(r.ok,r.error);return r;};
const before=await Promise.all(['a','b'].map(read));
await page.locator('#builder-target').selectOption('a');
await page.locator('#builder-fields select[data-marker=theme]').waitFor();
const generate=async()=>{const response=page.waitForResponse(r=>r.url().endsWith('/api/link-builder')&&r.request().method()==='POST');await page.getByRole('button',{name:'Сформировать и сохранить',exact:true}).click();const result=await(await response).json();assert.ok(result.ok,result.error);await page.waitForFunction(url=>document.querySelector('#builder-result').hidden===false&&document.querySelector('#builder-url').value===url,result.url);return result.url;};
const expectedTheme='rnd';await page.locator('#builder-fields select[data-marker=theme]').selectOption(expectedTheme);
const first=await generate();assert.equal(new URL(first).searchParams.get('theme')[4],'0');
const selectionA=await page.request.get('/api/link-builder?target=a').then(r=>r.json());assert.equal(selectionA.selection.theme,'rnd');assert.equal(selectionA.url,first);
const nextReg=selectionA.selection.reg==='rnd'?'1':'rnd';await page.locator('#builder-fields select[data-marker=reg]').selectOption(nextReg);
assert.equal(await page.locator('#builder-result').isVisible(),false,'old saved URL hidden for unsaved constructor changes');
const updated=await generate();const saved=await page.request.get('/api/link-builder?target=a').then(r=>r.json());assert.equal(saved.selection.reg,nextReg);for(const marker of Object.keys(selectionA.selection))if(marker!=='reg')assert.equal(saved.selection[marker],selectionA.selection[marker]);assert.notEqual(updated,first);
await page.locator('#source-target').selectOption('a');await page.getByLabel('Основной сайт',{exact:true}).waitFor();await page.getByLabel('Основной сайт',{exact:true}).fill('draft-only.example.org');
const version=await page.locator('#read-state').innerText();
await page.getByRole('button',{name:'Обновить сводку',exact:true}).click();
await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);
assert.equal(await page.getByLabel('Основной сайт',{exact:true}).inputValue(),'draft-only.example.org');assert.equal(await page.locator('#read-state').innerText(),version);assert.match(await page.locator('#change-summary').innerText(),/host/);
assert.equal((await read('a')).config.host,before[0].config.host,'overview/constructor did not save editor draft');
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/overview-builder-desktop.png',fullPage:true});
await page.reload();await page.locator('#workspace').waitFor({state:'visible'});await page.locator('#builder-target').selectOption('a');await page.locator('#builder-url').waitFor({state:'visible'});assert.equal(await page.locator('#builder-url').inputValue(),updated);assert.equal(await page.locator('#builder-fields select[data-marker=theme]').inputValue(),'rnd');assert.equal(await page.locator('#builder-fields select[data-marker=reg]').inputValue(),nextReg);
await page.locator('#builder-target').selectOption('b');await page.locator('#builder-fields select[data-marker=theme]').waitFor();const persistedB=await page.request.get('/api/link-builder?target=b').then(r=>r.json());if(persistedB.selection)assert.equal(await page.locator('#builder-fields select[data-marker=theme]').inputValue(),persistedB.selection.theme);
const after=await Promise.all(['a','b'].map(read));for(let i=0;i<2;i++){assert.equal(after[i].version,before[i].version);assert.deepEqual(after[i].config,before[i].config);}
assert.deepEqual(mutations,[],'no config/job/sets mutations from overview or constructor');assert.deepEqual(errors,[]);
await page.setViewportSize({width:390,height:844});await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/overview-builder-mobile.png',fullPage:true});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);
fs.writeFileSync((process.env.UI_OUTPUT||'/tmp')+'/overview-builder-evidence.json',JSON.stringify({configVersions:after.map(r=>r.version),selection:saved.selection,url:saved.url,mutations,pageErrors:errors},null,2));
console.log('PASS real overview / independent constructor / saved selection URL reload / no config writes / editor isolation / desktop mobile');
}finally{await browser.close();}
})().catch(error=>{console.error(error);process.exit(1);});