feat(admin): скрытие доменов и сохраняемый порядок колонок (#26)
This commit is contained in:
1 parent
dd6d221dd7
commit
00fbef9ede
12 files changed
+252
-33
No files matched your search
@@ -15,11 +15,33 @@ 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,$anonymousHeaders]=request('/api/state');check($status===401&&!$body['ok'],'HTTP unauthenticated state blocked');
|
||||
check(!array_filter($anonymousHeaders,static fn($header)=>str_starts_with(strtolower($header),'set-cookie: preland_admin=')),'HTTP anonymous state cannot overwrite a later authenticated cookie');
|
||||
[$status,$body]=request('/api/link-builder?target=test');check($status===401,'HTTP link builder requires authentication');
|
||||
foreach(['/api/targets/visibility','/api/targets/order'] as $route){[$status]=request($route,'POST',['id'=>'test']);check($status===401,'HTTP target organization 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');
|
||||
[$status]=request('/api/targets/visibility','POST',['id'=>'test','hidden'=>true]);check($status===403,'HTTP visibility requires CSRF');
|
||||
[$status]=request('/api/targets/order','POST',['ids'=>['test']]);check($status===403,'HTTP order requires CSRF');
|
||||
[$status,$body]=request('/api/targets/visibility','POST',['id'=>'test','hidden'=>true],$csrf);check($status===200&&$body['targets'][0]['hidden']===true&&!isset($body['targets'][0]['secret']),'HTTP visibility returns redacted updated targets');
|
||||
foreach([['id'=>'test','hidden'=>'true'],['id'=>'test','hidden'=>1],['id'=>['test'],'hidden'=>true],['id'=>'test'],['id'=>'test','hidden'=>false,'enabled'=>true]] as $invalid){[$status]=request('/api/targets/visibility','POST',$invalid,$csrf);check($status===422,'HTTP visibility rejects wrong shapes and extra fields');}
|
||||
[$status]=request('/api/targets/visibility','POST',['id'=>'absent','hidden'=>true],$csrf);check($status===404,'HTTP visibility missing target');
|
||||
foreach([['ids'=>(object)['0'=>'test']],['ids'=>'test'],['ids'=>[1]],['ids'=>[],'extra'=>true]] as $invalid){[$status]=request('/api/targets/order','POST',$invalid,$csrf);check($status===422,'HTTP order requires only a JSON string array');}
|
||||
[$status,$body]=request('/api/targets/order','POST',['ids'=>[]],$csrf);check($status===200&&$body['targets'][0]['id']==='test','HTTP all-hidden empty order accepted');
|
||||
[$status]=request('/api/targets/order','POST',['ids'=>['test']],$csrf);check($status===409,'HTTP stale visible order conflicts after hide');
|
||||
[$status]=request('/api/targets/visibility','POST',['id'=>'test','hidden'=>false],$csrf);check($status===200,'HTTP restore visible target');
|
||||
foreach([[],['test','test'],['absent']] as $invalid){[$status]=request('/api/targets/order','POST',['ids'=>$invalid],$csrf);check($status===409,'HTTP incomplete duplicate unknown order conflicts');}
|
||||
[$status]=request('/api/targets','DELETE',['id'=>'test'],$csrf);check($status===409,'HTTP visible deletion rejected');
|
||||
[$status]=request('/api/targets','DELETE',['id'=>'absent'],$csrf);check($status===404,'HTTP missing deletion rejected');
|
||||
[$status]=request('/api/targets','POST',['target'=>['id'=>'delete-fixture','domain'=>'delete.invalid','type'=>'local','root'=>$root.'/site','enabled'=>true]],$csrf);check($status===200,'HTTP creates disposable deletion fixture');
|
||||
[$status]=request('/api/targets/visibility','POST',['id'=>'delete-fixture','hidden'=>true],$csrf);check($status===200,'HTTP hides disposable deletion fixture');
|
||||
[$status,$body]=request('/api/sets/check','POST',['targets'=>['delete-fixture'],'number'=>1],$csrf);check($status===202,'HTTP hidden target remains available to explicit jobs');$deleteJob=$body['job']['id'];
|
||||
[$status]=request('/api/targets','DELETE',['id'=>'delete-fixture'],$csrf);check($status===409,'HTTP queued job prevents hidden deletion');
|
||||
$pending=$store->read('jobs/'.$deleteJob.'.json');$pending['status']='completed';$store->write('jobs/'.$deleteJob.'.json',$pending);
|
||||
[$status]=request('/api/targets','DELETE',['id'=>'delete-fixture'],$csrf);check($status===200&&is_file($root.'/site/config.json'),'HTTP idle hidden deletion keeps site config');
|
||||
$countBefore=count(glob($store->dir.'/jobs/*.json'));
|
||||
[$status]=request('/api/sets/check','POST',['targets'=>['delete-fixture'],'number'=>1],$csrf);check($status===404&&count(glob($store->dir.'/jobs/*.json'))===$countBefore,'HTTP stale enqueue cannot create job after connection deletion');
|
||||
[$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'];
|
||||
|
||||
@@ -29,7 +29,8 @@ function rememberBacking(string $cookie): array {
|
||||
try{
|
||||
$a=rememberServer('a');
|
||||
[$status]=rememberRequest($a,'/api/state');rememberCheck($status===401&&!is_dir($rememberState.'/remember-sessions'),'anonymous request does not create long-lived backing');
|
||||
[$status]=rememberRequest($a,'/api/login','',['username'=>'remember-fixture','password'=>'wrong','remember'=>true]);rememberCheck($status===401&&!is_dir($rememberState.'/remember-sessions'),'failed remembered login does not create long-lived backing');
|
||||
[$status,$ignored,$ignoredCookie,$failedCookie]=rememberRequest($a,'/api/login','',['username'=>'remember-fixture','password'=>'wrong','remember'=>true]);rememberCheck($status===401&&!is_dir($rememberState.'/remember-sessions'),'failed remembered login does not create long-lived backing');
|
||||
rememberCheck($failedCookie===null,'failed login cannot replace a newer authenticated cookie');
|
||||
foreach(['true',1,null,[],(object)['value'=>true]] as $bad){[$status]=rememberLogin($a,$bad);rememberCheck($status===422,'remember input requires strict boolean');}
|
||||
[$status,$regular,$regularCookie,$header]=rememberLogin($a,false);rememberCheck($status===200&&!str_contains(strtolower((string)$header),'expires=')&&!str_contains(strtolower((string)$header),'max-age='),'unchecked login issues browser-session cookie');
|
||||
$normalFile=rememberFile($regularCookie,false);rememberCheck(is_file($normalFile)&&!is_dir($rememberState.'/remember-sessions'),'regular login remains in short-lived backing');
|
||||
@@ -54,7 +55,8 @@ try{
|
||||
$other=rememberServer('other',$rememberRoot.'/other-state');[$status]=rememberRequest($other,'/api/state',$cookie);rememberCheck($status===401,'remember cookie cannot authenticate against unrelated state');
|
||||
// Абсолютный срок важнее свежего last; пограничный срок ещё до 90 дней допустим.
|
||||
rememberEdit($cookie,'remember_until',time()+5);rememberEdit($cookie,'last',time()-7200);[$status]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===200,'remembered session before absolute deadline ignores regular idle expiry');
|
||||
rememberEdit($cookie,'remember_until',time()-1);rememberEdit($cookie,'last',time());[$status,$body,$ignored,$expiredHeader]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401&&!is_file(rememberFile($cookie))&&str_contains(strtolower((string)$expiredHeader),'expires='),'absolute expiry destroys remembered backing and clears cookie despite fresh last');
|
||||
rememberEdit($cookie,'remember_until',time()-1);rememberEdit($cookie,'last',time());[$status,$body,$ignored,$expiredHeader]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401&&!is_file(rememberFile($cookie))&&$expiredHeader===null,'absolute expiry revokes backing without sending a cookie that can erase a newer login');
|
||||
[$status,$ignored,$ignoredCookie,$replayedHeader]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401&&$replayedHeader===null&&!is_file(rememberFile($cookie)),'expired cookie remains unauthenticated on replay after server revocation');
|
||||
[$status,$login,$cookie]=rememberLogin($b,true);$longFile=rememberFile($cookie);[$status,$newLogin,$newCookie,$header]=rememberLogin($b,false,$cookie);rememberCheck($status===200&&$newCookie!==$cookie&&!is_file($longFile)&&is_file(rememberFile($newCookie,false))&&!str_contains(strtolower((string)$header),'expires='),'remember to regular login destroys long-lived SID and restores session-only cookie');
|
||||
[$status]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401,'old remembered cookie cannot be reused after regular login');
|
||||
$normal=rememberFile($newCookie,false);$raw=file_get_contents($normal);file_put_contents($normal,preg_replace('/last\|i:\d+;/','last|i:'.(time()-3601).';',$raw));[$status]=rememberRequest($b,'/api/state',$newCookie);rememberCheck($status===401,'unchecked login still enforces one-hour idle expiry');
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__.'/backend.php';
|
||||
|
||||
function targetProblem(callable $action,int $status,string $message): void {
|
||||
try {$action();throw new RuntimeException($message.': accepted');}
|
||||
catch(PrelandAdmin\Problem $e){check($e->status===$status,$message);}
|
||||
}
|
||||
$legacy=$store->read('targets.json');
|
||||
unset($legacy['test']['hidden']);$store->write('targets.json',$legacy);
|
||||
check($store->targets()[0]['hidden']===false,'legacy target is visible by default');
|
||||
check($store->target('test')===$legacy['test'],'legacy raw target identity remains unchanged');
|
||||
foreach(['b','c','d'] as $id)$store->saveTarget(['id'=>$id,'domain'=>$id.'.invalid','type'=>'local','root'=>$root.'/site']);
|
||||
$store->setTargetVisibility('b',true);
|
||||
$before=$store->target('b');
|
||||
check($before['hidden']===true,'visibility persists independently of transport');
|
||||
$unchanged=$legacy['test'];$store->setTargetVisibility('test',true);$afterVisibility=$store->target('test');unset($afterVisibility['hidden']);
|
||||
check($afterVisibility===$unchanged,'visibility changes no credentials enabled verified or transport fields');
|
||||
$store->setTargetVisibility('test',false);
|
||||
$store->saveTarget(['id'=>'b','hidden'=>false]);
|
||||
check($store->target('b')['hidden']===true,'editing connection cannot override hidden state');
|
||||
$store->saveTarget(['id'=>'new','domain'=>'new.invalid','type'=>'local','root'=>$root.'/site','hidden'=>true]);
|
||||
check($store->target('new')['hidden']===false,'new connection is visible and appended');
|
||||
$store->orderTargets(['d','test','new','c']);
|
||||
check(array_column((new PrelandAdmin\Store($root.'/state'))->targets(),'id')===['d','b','test','new','c'],'reorder persists while preserving hidden slot');
|
||||
$store->setTargetVisibility('b',false);
|
||||
check(array_column($store->targets(),'id')===['d','b','test','new','c'],'restore returns target to preserved slot');
|
||||
$unchanged=$store->read('targets.json');
|
||||
foreach([['d','b','test','new'],['d','b','test','new','new'],['d','b','test','new','missing']] as $ids)targetProblem(fn()=>$store->orderTargets($ids),409,'stale incomplete duplicate or unknown order is rejected');
|
||||
check($store->read('targets.json')===$unchanged,'rejected reorder does not alter persisted state');
|
||||
targetProblem(fn()=>$store->orderTargets(['d',1,'test','new','c']),422,'order requires string IDs');
|
||||
targetProblem(fn()=>$store->setTargetVisibility('missing',true),404,'visibility missing target returns 404');
|
||||
targetProblem(fn()=>$store->deleteTarget('missing'),404,'delete missing target returns 404');
|
||||
targetProblem(fn()=>$store->deleteTarget('b'),409,'visible target cannot be deleted');
|
||||
$store->setTargetVisibility('b',true);
|
||||
$job=$jobs->create('check',['targets'=>['b'],'number'=>1]);
|
||||
targetProblem(fn()=>$store->deleteTarget('b'),409,'queued target cannot be deleted');
|
||||
$rawJob=$store->read('jobs/'.$job['id'].'.json');$rawJob['status']='running';$store->write('jobs/'.$job['id'].'.json',$rawJob);
|
||||
targetProblem(fn()=>$store->deleteTarget('b'),409,'running target cannot be deleted');
|
||||
check($store->target('b')['root']===$before['root'],'hiding and rejected deletion retain usable target transport');
|
||||
$rawJob['status']='completed';$store->write('jobs/'.$job['id'].'.json',$rawJob);
|
||||
$job=$jobs->create('copy',['source'=>['id'=>'b'],'targets'=>[['id'=>'test']]]);
|
||||
targetProblem(fn()=>$store->deleteTarget('b'),409,'queued copy source cannot be deleted');
|
||||
$rawJob=$store->read('jobs/'.$job['id'].'.json');$rawJob['status']='completed';$store->write('jobs/'.$job['id'].'.json',$rawJob);
|
||||
$store->deleteTarget('b');
|
||||
check(array_column($store->targets(),'id')===['d','test','new','c'],'hidden idle target deletes only its connection');
|
||||
check(is_file($root.'/site/config.json'),'connection deletion keeps site files');
|
||||
foreach(['d','test','new','c'] as $id)$store->setTargetVisibility($id,true);
|
||||
$store->orderTargets([]);
|
||||
check(array_column($store->targets(),'id')===['d','test','new','c'],'empty visible order preserves all hidden connections');
|
||||
echo "Targets Store contract complete\n";
|
||||
@@ -0,0 +1,22 @@
|
||||
/* preland#26: late anonymous401 must preserve UI, notice and actual authenticated session. */
|
||||
const {chromium}=require('playwright-core'),assert=require('node:assert/strict');
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});
|
||||
const options={baseURL:process.env.ADMIN_URL||'http://admin'};const page=await browser.newPage(options);let original=[];
|
||||
const login=async p=>{await p.goto('/');await p.getByLabel('Логин',{exact:true}).fill('admin');await p.getByLabel('Пароль',{exact:true}).fill('test-password');await p.getByRole('button',{name:'Войти',exact:true}).click();await p.locator('#workspace').waitFor({state:'visible'});};
|
||||
try{
|
||||
let release,ready,held=false;const gate=new Promise(r=>release=r),captured=new Promise(r=>ready=r);
|
||||
await page.route('**/api/state',async route=>{if(held)return route.continue();held=true;const response=await route.fetch();assert.equal(response.status(),401);ready();await gate;await route.fulfill({response});});
|
||||
const entered=login(page);await captured;await entered;
|
||||
original=await page.locator('[data-overview-target]').evaluateAll(ns=>ns.map(n=>n.dataset.overviewTarget));const id=original[0];
|
||||
await page.locator('[data-overview-target="'+id+'"] .domain-grip').press('ArrowRight');await page.waitForFunction(()=>document.querySelector('#notice').textContent==='Порядок доменов сохранён.');
|
||||
release();await page.waitForTimeout(500);
|
||||
assert.equal(await page.locator('#workspace').isVisible(),true,'late anonymous401 must not destroy successful login');
|
||||
assert.equal(await page.locator('#notice').innerText(),'Порядок доменов сохранён.','late response must not replace successful mutation notice');
|
||||
const current=await page.request.get('/api/state');assert.equal(current.status(),200,'late anonymous401 must not overwrite authenticated session cookie');
|
||||
console.log('PASS late anonymous401 preserves workspace/success notice/authenticated session');
|
||||
}finally{
|
||||
if(original.length){const cleanup=await browser.newPage(options);await login(cleanup);const state=await cleanup.request.get('/api/state').then(r=>r.json());const response=await cleanup.request.post('/api/targets/order',{data:{ids:original},headers:{'X-CSRF-Token':state.csrf}});assert.ok(response.ok(),'cleanup restores order');}
|
||||
await browser.close();
|
||||
}
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,10 @@
|
||||
const {chromium}=require('playwright-core');const assert=require('node:assert/strict');
|
||||
(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']});const context=await browser.newContext({baseURL:process.env.ADMIN_URL,viewport:{width:1440,height:1000}});const page=await context.newPage();page.on('dialog',d=>d.accept());const errors=[];page.on('pageerror',e=>errors.push(e.message));try{await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();const before=await page.locator('[data-overview-target]').evaluateAll(ns=>ns.map(n=>n.dataset.overviewTarget));assert.equal(await page.locator('.domain-hide').count(),before.length,'each domain has hide control');const first=before[0];await page.locator('[data-overview-target="'+first+'"] .domain-grip').press('ArrowRight');await page.waitForFunction(id=>document.querySelectorAll('[data-overview-target]')[1]?.dataset.overviewTarget===id,first);await page.reload();await page.locator('[data-overview-target]').first().waitFor();assert.equal(await page.locator('[data-overview-target]').nth(1).getAttribute('data-overview-target'),first);await page.locator('[data-overview-target="'+first+'"] .domain-hide').click();await page.locator('[data-overview-target="'+first+'"]').waitFor({state:'detached'});assert.equal(await page.locator('#notice').innerText(),'Домен скрыт.');for(const select of ['#source-target','#copy-source','#builder-target'])assert.equal(await page.locator(select+' option[value="'+first+'"]').count(),0);await page.getByRole('button',{name:/Скрытые домены/}).click();await page.locator('[data-hidden-target="'+first+'"] button').filter({hasText:'Вернуть'}).click();await page.locator('[data-overview-target="'+first+'"]').waitFor();await page.locator('[data-overview-target="'+first+'"] .domain-grip').press('ArrowLeft');await page.waitForFunction(id=>document.querySelector('[data-overview-target]')?.dataset.overviewTarget===id,first);
|
||||
// Native pointer path, preserving unrelated drafts, selection and shared note nodes.
|
||||
await page.locator('[data-host]').first().waitFor();const host=page.locator('[data-host]').first(),original=await host.inputValue();await host.fill('draft.example');const draftNode=await host.elementHandle();const noteRow=page.locator('tr[data-utm-note=reg]');await noteRow.getByRole('button',{name:/(Добавить|Редактировать) заметку reg/}).click();const note=noteRow.getByLabel('Заметка reg',{exact:true});await note.fill('Несохранённая заметка при переносе');const noteNode=await note.elementHandle();
|
||||
const second=before[1],grip=page.locator('[data-overview-target="'+second+'"] .domain-grip');await grip.scrollIntoViewIfNeeded();const box=await grip.boundingBox(),destination=await page.locator('[data-overview-target="'+first+'"]').boundingBox();await page.mouse.move(box.x+10,box.y+10);await page.mouse.down();await page.mouse.move(destination.x+15,destination.y+20,{steps:12});assert.equal(await page.locator('.domain-drag-preview').count(),1);await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/targets-drag.png'});await page.mouse.up();await page.waitForFunction(id=>document.querySelector('[data-overview-target]').dataset.overviewTarget===id,second);assert.equal(await draftNode.inputValue(),'draft.example');assert.equal(await noteNode.inputValue(),'Несохранённая заметка при переносе');assert.ok(await draftNode.evaluate(n=>n.isConnected));assert.ok(await noteNode.evaluate(n=>n.isConnected));
|
||||
await page.locator('[data-overview-target="'+first+'"] .domain-hide').click();assert.equal(await page.locator('[data-overview-target="'+first+'"]').count(),1);assert.match(await page.locator('#notice').innerText(),/Сначала сохраните/);await page.getByLabel('Отменить настройки '+first,{exact:true}).click();await noteRow.getByRole('button',{name:'Отменить заметку reg',exact:true}).click();await page.locator('[data-overview-target="'+first+'"] .domain-grip').press('ArrowLeft');await page.waitForFunction(id=>document.querySelector('[data-overview-target]').dataset.overviewTarget===id,first);
|
||||
// Server persistence for an independent authenticated device/context.
|
||||
const another=await browser.newContext({baseURL:process.env.ADMIN_URL});const remote=await another.newPage();await remote.goto('/');await remote.getByLabel('Логин',{exact:true}).fill('admin');await remote.getByLabel('Пароль',{exact:true}).fill('test-password');await remote.getByRole('button',{name:'Войти',exact:true}).click();await remote.locator('[data-overview-target]').first().waitFor();assert.deepEqual(await remote.locator('[data-overview-target]').evaluateAll(ns=>ns.map(n=>n.dataset.overviewTarget)),before);await another.close();
|
||||
for(const id of before){await page.locator('[data-overview-target="'+id+'"] .domain-hide').click();await page.locator('[data-overview-target="'+id+'"]').waitFor({state:'detached'});}assert.equal(await page.locator('#connection-new').isVisible(),true);assert.equal(await page.locator('#jobs table').count(),0);assert.match(await page.locator('#jobs').innerText(),/Заданий для видимых доменов пока нет/);await page.locator('#hidden-toggle').click();assert.equal(await page.locator('[data-hidden-target]').count(),before.length);await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/targets-hidden.png',fullPage:true});for(const id of before){await page.locator('[data-hidden-target="'+id+'"] button').filter({hasText:'Вернуть'}).click();await page.locator('[data-overview-target="'+id+'"]').waitFor();}await page.locator('#hidden-close').click();await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/targets-mobile.png'});assert.deepEqual(errors,[]);assert.equal(await page.locator('#notice').innerText(),'Домен возвращён.');assert.equal(await page.locator('#notice').getAttribute('class'),'alert alert-success');console.log('PASS visibility/order/native drag/drafts/notes/new context/all hidden/responsive UI');
|
||||
}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
Reference in new issue
Block a user