status===$status,$message);return;}throw new RuntimeException('Accepted invalid operation: '.$message);} notesCheck(class_exists(Notes::class),'Notes service is available'); $root=sys_get_temp_dir().'/utm-notes-'.bin2hex(random_bytes(8));$store=new Store($root);$notes=new Notes($store);$markers=['indpic','theme','degr','reg','banch','tp','cons','focus','part']; $runtime=$root.'/runtime.json';file_put_contents($runtime,'{"host":"untouched.example","utm":{}}');$runtimeHash=hash_file('sha256',$runtime); $all=$notes->all();notesCheck(array_keys($all)===$markers&&count(array_filter($all,static fn($n)=>$n['text']===''&&$n['saved_at']===null&&is_string($n['version'])))===9,'GET contract provides all nine empty notes with stable versions'); notesCheck(!file_exists($root.'/utm-notes.json')&&$notes->all()===$all,'initial read does not create state or regenerate versions'); $text="Общее описание для всех доменов\n\tпример\r\n😀"; $saved=$notes->save(['marker'=>'theme','text'=>$text,'version'=>$all['theme']['version']]);notesCheck($saved['text']===$text&&$saved['version']!==$all['theme']['version']&&is_string($saved['saved_at']),'save stores exact multiline Unicode plaintext with new revision and timestamp'); notesCheck((new Notes(new Store($root)))->all()['theme']===$saved,'fresh service reload returns exact saved note and version'); notesReject(fn()=>$notes->save(['marker'=>'theme','text'=>'stale','version'=>$all['theme']['version']]),409,'stale per-note version conflicts instead of overwriting'); $indpic=$notes->save(['marker'=>'indpic','text'=>'Independent note','version'=>$all['indpic']['version']]);notesCheck($notes->all()['theme']===$saved&&$indpic['text']==='Independent note','another marker can save its original revision without losing sibling note'); $cleared=$notes->save(['marker'=>'theme','text'=>'','version'=>$saved['version']]);notesCheck($cleared['text']===''&&$cleared['version']!==$all['theme']['version']&&$cleared['version']!==$saved['version'],'explicit clear keeps a new revision and prevents ABA'); notesReject(fn()=>$notes->save(['marker'=>'theme','text'=>'old initial','version'=>$all['theme']['version']]),409,'initial version remains stale after save then clear'); $large=str_repeat('😀',10000);$max=$notes->save(['marker'=>'cons','text'=>$large,'version'=>$all['cons']['version']]);notesCheck($max['text']===$large,'limit counts 10000 Unicode characters rather than UTF8 bytes'); foreach([['marker'=>'unknown','text'=>'x','version'=>'x'],['marker'=>'registration','text'=>'x','version'=>'x'],['marker'=>'theme','text'=>1,'version'=>'x'],['marker'=>'theme','text'=>'x','version'=>null],['marker'=>'theme','text'=>'x'],['marker'=>'theme','text'=>'x','version'=>'x','domain'=>'evil.example'],['marker'=>['theme'],'text'=>'x','version'=>'x'],['marker'=>'theme','text'=>str_repeat('a',10001),'version'=>'x'],['marker'=>'theme','text'=>"invalid\xFF",'version'=>'x'],['marker'=>'theme','text'=>"NUL\0",'version'=>'x'],['marker'=>'theme','text'=>"DEL\x7f",'version'=>'x'],['marker'=>'theme','text'=>"C1\u{0085}",'version'=>'x']] as $invalid)notesReject(fn()=>$notes->save($invalid),422,'invalid marker, fields, types, length, UTF8 or controls rejected'); $pids=[];$snapshot=$notes->all();foreach(['degr','tp'] as $marker){$pid=pcntl_fork();if($pid===0){try{(new Notes(new Store($root)))->save(['marker'=>$marker,'text'=>'parallel-'.$marker,'version'=>$snapshot[$marker]['version']]);exit(0);}catch(Throwable){exit(2);}}$pids[]=$pid;}foreach($pids as $pid){pcntl_waitpid($pid,$status);notesCheck(pcntl_wexitstatus($status)===0,'parallel unrelated note save succeeds');}notesCheck($notes->all()['degr']['text']==='parallel-degr'&&$notes->all()['tp']['text']==='parallel-tp','global file lock preserves concurrent writes to different notes'); $snapshot=$notes->all();$pids=[];foreach(['first','second'] as $text){$pid=pcntl_fork();if($pid===0){try{(new Notes(new Store($root)))->save(['marker'=>'reg','text'=>$text,'version'=>$snapshot['reg']['version']]);exit(0);}catch(Problem $e){exit($e->status===409?9:2);}}$pids[]=$pid;}$codes=[];foreach($pids as $pid){pcntl_waitpid($pid,$status);$codes[]=pcntl_wexitstatus($status);}sort($codes);notesCheck($codes===[0,9],'concurrent same-note saves produce one success and one conflict'); notesCheck((fileperms($root.'/utm-notes.json')&0777)===0600,'notes state is private 0600');$good=file_get_contents($root.'/utm-notes.json'); foreach(['{broken','null','[]','{"unexpected":{}}',str_replace('Independent note','bad\\u0000text',$good)] as $bad){file_put_contents($root.'/utm-notes.json',$bad);notesReject(fn()=>$notes->all(),503,'corrupt private note state fails closed');}file_put_contents($root.'/utm-notes.json',$good); file_put_contents($root.'/utm-notes.json','null');notesReject(fn()=>$notes->save(['marker'=>'indpic','text'=>'must not reset','version'=>$indpic['version']]),503,'write never resets corrupt note state');notesCheck(file_get_contents($root.'/utm-notes.json')==='null','failed corrupt-state write leaves evidence intact'); unlink($root.'/utm-notes.json');symlink($runtime,$root.'/utm-notes.json');notesReject(fn()=>$notes->all(),503,'note symlink read fails closed');notesReject(fn()=>$notes->save(['marker'=>'indpic','text'=>'must not follow','version'=>$indpic['version']]),503,'note symlink write fails closed');unlink($root.'/utm-notes.json');file_put_contents($root.'/utm-notes.json',$good);chmod($root.'/utm-notes.json',0600); notesCheck(hash_file('sha256',$runtime)===$runtimeHash&&glob($root.'/jobs/*.json')===[]&&!file_exists($root.'/targets.json'),'notes never modify runtime, targets or jobs'); // Настоящий HTTP-контракт, отдельный PHP server, обычный auth/CSRF. $env=getenv();$env['ADMIN_STATE_DIR']=$root;$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='notes-fixture';$env['ADMIN_PASSWORD_HASH']=password_hash('notes-fixture-password',PASSWORD_DEFAULT);unset($env['PHP_CLI_SERVER_WORKERS']);$port=random_int(19000,29000);$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);fclose($pipes[0]);$cookie=''; function notesRequest(string $method='GET',?array $data=null,?string $csrf=null,string $path='/api/utm-notes'): array {global $port,$cookie;$headers=['Content-Type: application/json'];if($cookie)$headers[]='Cookie: '.$cookie;if($csrf!==null)$headers[]='X-CSRF-Token: '.$csrf;$ctx=stream_context_create(['http'=>['method'=>$method,'header'=>implode("\r\n",$headers),'content'=>$data===null?'':json_encode((object)$data,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>10]]);$raw=file_get_contents('http://127.0.0.1:'.$port.$path,false,$ctx);$h=$http_response_header??[];preg_match('~HTTP/\S+ (\d+)~',$h[0]??'',$m);foreach($h as $line)if(str_starts_with(strtolower($line),'set-cookie: '))$cookie=explode(';',substr($line,12))[0];return [(int)($m[1]??0),json_decode($raw,true,64,JSON_THROW_ON_ERROR)];} try{$until=microtime(true)+5;do{$s=@fsockopen('127.0.0.1',$port);if($s){fclose($s);break;}usleep(20000);}while(microtime(true)<$until); [$status]=notesRequest();notesCheck($status===401,'HTTP notes require authentication');[$status,$login]=notesRequest('POST',['username'=>'notes-fixture','password'=>'notes-fixture-password'],null,'/api/login');notesCheck($status===200,'HTTP fixture login succeeds');$csrf=$login['csrf']; [$status,$body]=notesRequest();notesCheck($status===200&&array_keys($body['notes'])===$markers,'HTTP GET returns nine global note objects without target');$version=$body['notes']['focus']['version']; [$status]=notesRequest('POST',['marker'=>'focus','text'=>'csrf','version'=>$version]);notesCheck($status===403,'HTTP notes mutation requires CSRF'); [$status,$body]=notesRequest('POST',['marker'=>'focus','text'=>'HTTP global note','version'=>$version],$csrf);notesCheck($status===200&&$body['note']['text']==='HTTP global note','HTTP POST saves exact note response');$httpSaved=$body['note']; [$status,$body]=notesRequest();notesCheck($body['notes']['focus']===$httpSaved,'HTTP reload restores note across requests'); [$status]=notesRequest('POST',['marker'=>'focus','text'=>'stale HTTP','version'=>$version],$csrf);notesCheck($status===409,'HTTP stale editor gets 409 conflict'); [$status]=notesRequest('POST',['marker'=>'focus','text'=>'x','version'=>$httpSaved['version'],'target'=>'any'],$csrf);notesCheck($status===422,'HTTP note refuses domain scope and unknown top-level fields'); [$status]=notesRequest('POST',['marker'=>'focus','text'=>false,'version'=>$httpSaved['version']],$csrf);notesCheck($status===422,'HTTP strict note value types return 422'); file_put_contents($root.'/utm-notes.json','{bad');[$status]=notesRequest();notesCheck($status===503,'HTTP corrupt file returns safe 503');file_put_contents($root.'/utm-notes.json',$good); notesCheck(hash_file('sha256',$runtime)===$runtimeHash&&glob($root.'/jobs/*.json')===[]&&!file_exists($root.'/targets.json'),'HTTP notes leave runtime, jobs and target registry unchanged'); [$status]=notesRequest('POST',[],$csrf,'/api/logout');notesCheck($status===200,'HTTP note fixture logs out'); }finally{proc_terminate($server);proc_close($server);fclose($log);} echo "UTM notes contract complete\n";