68 lines
5.1 KiB
PHP
68 lines
5.1 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_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";
|