feat(admin): add runtime overview and independent saved UTM link builder
deploy-preland / deploy zoomstage.online (push) Successful in 57s
deploy-preland / deploy zoomstage.online (push) Successful in 57s
This commit is contained in:
1 parent
48c71f3300
commit
2cfa2e2d2e
10 files changed
+421
-8
No files matched your search
@@ -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";
|
||||
Reference in new issue
Block a user