Files
preland/admin/tests/link-builder.php
T

73 lines
5.5 KiB
PHP

<?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_keys($selection) as $key) {
$manualSelection=array_fill_keys(array_keys($selection),'rnd');$manualSelection[$key]='9';
$manual=$builder->save('second',$manualSelection);parse_str(parse_url($manual['url'],PHP_URL_QUERY),$manualParameters);
linkCheck($builder->get('second')===$manual&&$manual['selection'][$key]==='9'&&$manualParameters[$key][4]==='9','manual digit 9 encodes and persists for '.$key);
}
foreach ([[], array_diff_key($selection,['focus'=>true]), $selection+['unexpected'=>'1'], array_replace($selection,['theme'=>'10']), 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";