Add human support form with fixed email recipient (#40)

Add Help contact form and PHP mail endpoint for support@katerion.ru, with validated Reply-To, explicit diagnostics consent, CSRF/origin checks, hashed rate limits and duplicate protection. Preserve drafts on error and avoid serializing customer data or SDK internals. Full PR QA passed in 35559317001; isolated PHP and 30 browser cases passed in 35559179372. Standard production gates unchanged. Publication checks do not send real mail; inbox receipt remains unverified. No training photo assets or unfinished training lifecycle changes.
This commit is contained in:
pavlov346346-source 2026-09-21 07:12:26 +03:00 committed by GitHub
parent 298b29cd52
commit c3cb44419b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 431 additions and 6 deletions

View File

@ -0,0 +1,73 @@
name: Support form checks
on:
push:
branches: [feature/support-mail-20260921]
paths: [ops/finalize-support-form.py, '.github/workflows/support-form-check.yml']
workflow_run:
workflows: [Verify Caterium on Timeweb]
types: [completed]
permissions:
contents: read
jobs:
test:
if: github.event_name == 'push'
permissions:
contents: write
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Finalize feature-only source
run: |
set -euo pipefail
test "$GITHUB_REF_NAME" = 'feature/support-mail-20260921'
if [ -f ops/finalize-support-form.py ]; then
python ops/finalize-support-form.py
rm ops/finalize-support-form.py
fi
git diff --check
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: php -l public/api/support.php && php -l public/api/support-lib.php && php tests/support-mail.php
- run: node --check public/core/support-form.js && node --check public/core/help-center.js
- run: npx playwright install --with-deps chromium webkit
- run: npx playwright test --config=tests/playwright.config.mjs tests/support-form.spec.mjs tests/help-center.spec.mjs --reporter=list
- name: Save verified source to feature branch only
run: |
set -euo pipefail
git config user.name 'Caterium support verification'
git config user.email 'caterium-verification@users.noreply.github.com'
git add public/core/support-form.js public/service-worker.js tests/support-form.spec.mjs
git add -u ops/
if ! git diff --cached --quiet; then
git commit -m 'Finalize verified support mail lifecycle and cache update'
git push origin HEAD:feature/support-mail-20260921
fi
- uses: actions/upload-artifact@v4
if: always()
with:
name: support-form-test-results
path: test-results/
retention-days: 3
published:
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
timeout-minutes: 6
steps:
- uses: actions/checkout@v4
with:
ref: production
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: node tests/production-support-form.mjs
- uses: actions/upload-artifact@v4
if: always()
with:
name: published-support-form
path: production-ui-results/
retention-days: 7

View File

@ -0,0 +1,12 @@
# Contact support from Help
Recipient is exactly `support@katerion.ru` (explicit user instruction; do not silently rewrite to caterium.ru). The Help dialog offers a human support tab and an unanswered-question call to action. It also works without login. The AI assistant stays separate.
Submission uses same-origin `/api/support.php`, not mailto. Required: name, reply email, topic, subject and message. Diagnostics are opt-in and contain only browser, viewport, current section and release strings. No SDK, session, token, order, customer record or URL query/hash is serialized. Drafts remain in memory on errors/close and are cleared on account/workspace changes. Only confirmed `202 accepted` clears the message. No autoresponder, attachments, arbitrary recipient or database access.
The PHP endpoint uses the existing Timeweb shared-hosting mail agent. From and envelope sender: no-reply@caterium.ru; Reply-To is the validated user email, noted as self-reported in the message. The recipient is hardcoded. Hosting must have PHP mail enabled and the sender domain mail/DNS policy must allow hosting mail. Mail acceptance is NOT proof of inbox delivery.
References: https://timeweb.com/ru/docs/pochta/osnovnye-voprosy-po-rabote-s-pochtoj/rabota-s-php-mail/ and https://www.php.net/manual/en/function.mail.php
Guards: exact host/origin, JSON-only POST, secure HttpOnly SameSite CSRF session, length/type checks, no header injection, honeypot, locked limits (5/IP/hour, 3/reply-email/hour, 60 total/hour), idempotency for 48 hours and pending state before delivery. Failed/uncertain mail never returns success. Rate/idempotency records hold hashes/statuses, NOT bodies or plaintext contacts, outside public_html in .caterium-support (0700/0600), pruned after 48 hours on submissions. Optional server-only CATERIUM_SUPPORT_STATE_DIR must also be outside the web root. Deployment does not delete it. Mail/session server retention is separate.
Automated tests do not send real mail: unit tests capture mail(); UI tests mock the endpoint; publication checks real PHP session/CSRF and cross-origin rejection plus exact public JS and the rendered form on 390/1440 px. Confirm mailbox receipt with one labelled message before announcing end-to-end delivery. No live customer data or MFA altered.

View File

@ -0,0 +1,79 @@
<?php
/** Fixed-recipient contact form. No customer database access and no mail relay. */
declare(strict_types=1);
namespace Caterium\Support;
const RECIPIENT = 'support@katerion.ru';
const SENDER = 'no-reply@caterium.ru';
const TOPICS = ['question'=>'Вопрос по приложению','problem'=>'Ошибка или проблема','access'=>'Вход и подписка','suggestion'=>'Предложение','other'=>'Другое'];
final class Problem extends \RuntimeException {
public $status;
public function __construct(int $status, string $message) { parent::__construct($message); $this->status=$status; }
}
function text(array $input, string $key, int $max, bool $required=false, bool $multiline=false): string {
$value=$input[$key]??'';
if (!is_string($value) || !preg_match('//u',$value)) throw new Problem(422,'Проверьте поля формы.');
$value=trim($value);
if (strlen($value)>$max || ($required && $value==='') || preg_match($multiline?'/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/':'/[\x00-\x1f\x7f]/',$value)) throw new Problem(422,'Поле «'.$key.'» заполнено неверно или слишком длинное.');
return str_replace(["\r\n","\r"],"\n",$value);
}
function validate(array $input): array {
$keys=['name','email','topic','subject','message','device','section','release','website','request_id','csrf'];
if (array_diff(array_keys($input),$keys)) throw new Problem(422,'Неизвестные поля формы.');
$row=['name'=>text($input,'name',240,true),'email'=>text($input,'email',254,true),'topic'=>text($input,'topic',30,true),
'subject'=>text($input,'subject',360,true),'message'=>text($input,'message',16000,true,true),
'device'=>text($input,'device',1000),'section'=>text($input,'section',240),'release'=>text($input,'release',80),
'request_id'=>text($input,'request_id',80,true)];
if (!filter_var($row['email'],FILTER_VALIDATE_EMAIL) || !preg_match('/^[\x21-\x7e]+$/',$row['email']) || !isset(TOPICS[$row['topic']]) || !preg_match('/^[a-f0-9-]{36}$/',$row['request_id'])) throw new Problem(422,'Проверьте email и тему обращения.');
if (text($input,'website',200)!=='') throw new Problem(422,'Не удалось проверить форму. Обновите страницу.');
return $row;
}
function directory(): string {
// Outside public_html and outside the release backup; no message bodies are stored.
$dir=getenv('CATERIUM_SUPPORT_STATE_DIR')?:dirname(__DIR__,2).'/.caterium-support';
if (!is_dir($dir) && !@mkdir($dir,0700,true) && !is_dir($dir)) throw new Problem(503,'Форма временно недоступна. Напишите на '.RECIPIENT.'.');
$real=realpath($dir);$web=realpath(dirname(__DIR__));
if (!$real || ($web && ($real===$web || strpos($real,$web.DIRECTORY_SEPARATOR)===0))) throw new Problem(503,'Не удалось подготовить защищённую отправку.');
return $real;
}
function deliver(array $row, string $id): bool {
if (!function_exists('mail')) return false;
$body="Обращение в поддержку Caterium\nНомер: $id\nUTC: ".gmdate('c')."\n\nИмя: {$row['name']}\nEmail для ответа (указан пользователем): {$row['email']}\nКатегория: ".TOPICS[$row['topic']]."\nТема: {$row['subject']}\n\n{$row['message']}\n";
if ($row['device']!=='') $body.="\nСведения, разрешённые отправителем:\nУстройство: {$row['device']}\nРаздел: {$row['section']}\nВерсия: {$row['release']}\n";
$headers=['From'=>'Caterium <'.SENDER.'>','Reply-To'=>$row['email'],'MIME-Version'=>'1.0','Content-Type'=>'text/plain; charset=UTF-8','Content-Transfer-Encoding'=>'base64','Auto-Submitted'=>'auto-generated','X-Auto-Response-Suppress'=>'All','Message-ID'=>'<'.strtolower($id).'@caterium.ru>'];
// All addresses and the envelope sender are fixed or strictly validated.
return @mail(RECIPIENT,'[Caterium] Support request '.$id,chunk_split(base64_encode($body),76,"\r\n"),$headers,'-f'.SENDER);
}
function submit(array $row, string $ip, string $dir, callable $mailer, ?int $now=null): array {
$now=$now??time();$path=$dir.'/limits.json';
if (is_link($path)) throw new Problem(503,'Форма временно недоступна.');
$handle=@fopen($path,'c+');if (!$handle || !flock($handle,LOCK_EX)) throw new Problem(503,'Форма временно недоступна. Попробуйте позже.');
@chmod($path,0600);
try {
$raw=stream_get_contents($handle);$state=$raw===''?['salt'=>bin2hex(random_bytes(32)),'requests'=>[]]:json_decode($raw,true);
if (!is_array($state) || !is_string($state['salt']??null) || !is_array($state['requests']??null)) throw new Problem(503,'Форма временно недоступна.');
$hash=static function(string $s)use($state):string{return hash_hmac('sha256',$s,$state['salt']);};
$ipHash=$hash('ip:'.$ip);$emailHash=$hash('email:'.strtolower($row['email']));$key=$hash($emailHash.':'.$row['request_id']);
$fingerprint=$hash(json_encode($row,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR));
$state['requests']=array_filter($state['requests'],static function($x)use($now){return ($x['at']??0)>$now-172800;});
if (isset($state['requests'][$key])) {
$previous=$state['requests'][$key];
if (!hash_equals($previous['fingerprint'],$fingerprint)) throw new Problem(409,'Это обращение изменено. Создайте новое сообщение.');
if ($previous['status']==='accepted') return ['ok'=>true,'status'=>'accepted','id'=>$previous['id']];
if ($previous['status']==='pending') throw new Problem(409,'Результат отправки пока не подтверждён. Номер '.$previous['id'].'. Не дублируйте сообщение; при необходимости напишите на '.RECIPIENT.'.');
if ($previous['at']>$now-60) throw new Problem(429,'Подождите минуту перед повторной отправкой.');
}
$hour=array_filter($state['requests'],static function($x)use($now){return $x['at']>$now-3600;});
if (count($hour)>=60 || count(array_filter($hour,static function($x)use($ipHash){return $x['ip']===$ipHash;}))>=5 || count(array_filter($hour,static function($x)use($emailHash){return $x['email']===$emailHash;}))>=3) throw new Problem(429,'Слишком много обращений. Попробуйте через час или напишите на '.RECIPIENT.'.');
$id='SUP-'.gmdate('Ymd',$now).'-'.strtoupper(substr($key,0,12));
$state['requests'][$key]=['at'=>$now,'ip'=>$ipHash,'email'=>$emailHash,'fingerprint'=>$fingerprint,'id'=>$id,'status'=>'pending'];
$save=static function()use(&$state,$handle):void{
$json=json_encode($state,JSON_THROW_ON_ERROR);rewind($handle);
if (!ftruncate($handle,0) || fwrite($handle,$json)!==strlen($json) || !fflush($handle)) throw new Problem(503,'Не удалось подтвердить отправку. Не дублируйте письмо сразу.');
};
$save(); // Record uncertain state BEFORE handing off to the mail server.
$accepted=$mailer($row,$id)===true;
$state['requests'][$key]['status']=$accepted?'accepted':'failed';$save();
if (!$accepted) throw new Problem(503,'Почтовый сервер не принял сообщение. Текст сохранён в форме. Попробуйте позже или напишите на '.RECIPIENT.'.');
return ['ok'=>true,'status'=>'accepted','id'=>$id];
} finally {flock($handle,LOCK_UN);fclose($handle);}
}

43
public/api/support.php Normal file
View File

@ -0,0 +1,43 @@
<?php
/** Same-origin public help endpoint; no credentials or customer data required. */
declare(strict_types=1);
require_once __DIR__.'/support-lib.php';
use Caterium\Support\Problem;
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: private, no-store');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
try {
if (strtolower(explode(':',$_SERVER['HTTP_HOST']??'')[0])!=='app.caterium.ru') throw new Problem(404,'Not found');
$method=$_SERVER['REQUEST_METHOD']??'';
if (!in_array($method,['GET','POST'],true)) {header('Allow: GET, POST');throw new Problem(405,'Метод не поддерживается.');}
$origin=$_SERVER['HTTP_ORIGIN']??'';
if (($method==='POST' && $origin!=='https://app.caterium.ru') || ($origin!=='' && $origin!=='https://app.caterium.ru') || ($_SERVER['HTTP_SEC_FETCH_SITE']??'same-origin')==='cross-site') throw new Problem(403,'Откройте форму в приложении Caterium.');
if (!function_exists('mail')) throw new Problem(503,'Отправка почты временно недоступна. Напишите на '.Caterium\Support\RECIPIENT.'.');
$dir=Caterium\Support\directory();
session_name('ct_support');session_set_cookie_params(['lifetime'=>0,'path'=>'/api/','secure'=>true,'httponly'=>true,'samesite'=>'Strict']);
ini_set('session.use_strict_mode','1');
if (!session_start()) throw new Problem(503,'Не удалось подготовить форму.');
if ($method==='GET') {
if (!isset($_SESSION['csrf']) || ($_SESSION['issued']??0)<time()-7200) {$_SESSION['csrf']=bin2hex(random_bytes(32));$_SESSION['issued']=time();}
$out=['csrf'=>$_SESSION['csrf'],'recipient'=>Caterium\Support\RECIPIENT,'max_message_chars'=>4000];session_write_close();
echo json_encode($out,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);exit;
}
if (strtolower(trim(explode(';',$_SERVER['CONTENT_TYPE']??'')[0]))!=='application/json') throw new Problem(415,'Ожидается форма JSON.');
if ((int)($_SERVER['CONTENT_LENGTH']??0)>24000) throw new Problem(413,'Сообщение слишком длинное.');
$raw=file_get_contents('php://input',false,null,0,24001);
if ($raw===false || strlen($raw)>24000) throw new Problem(413,'Сообщение слишком длинное.');
try {$input=json_decode($raw,true,8,JSON_THROW_ON_ERROR);}catch(\JsonException $e){throw new Problem(400,'Не удалось прочитать форму.');}
if (!is_array($input) || !is_string($input['csrf']??null) || !isset($_SESSION['csrf']) || ($_SESSION['issued']??0)<time()-7200 || !hash_equals($_SESSION['csrf'],$input['csrf'])) throw new Problem(403,'Форма устарела. Откройте её заново; текст не нужно удалять.');
session_write_close();
$row=Caterium\Support\validate($input);
$out=Caterium\Support\submit($row,$_SERVER['REMOTE_ADDR']??'unknown',$dir,'Caterium\\Support\\deliver');
http_response_code(202);echo json_encode($out,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);
} catch (Problem $e) {
if(session_status()===PHP_SESSION_ACTIVE)session_write_close();
http_response_code($e->status);if($e->status===429)header('Retry-After: 3600');
echo json_encode(['ok'=>false,'message'=>$e->getMessage()],JSON_UNESCAPED_UNICODE);
} catch (\Throwable $e) {
if(session_status()===PHP_SESSION_ACTIVE)session_write_close();
http_response_code(503);echo json_encode(['ok'=>false,'message'=>'Не удалось подтвердить отправку. Сохраните текст и напишите на '.Caterium\Support\RECIPIENT.'.'],JSON_UNESCAPED_UNICODE);
}

View File

@ -6,6 +6,13 @@
const norm=s=>String(s).toLowerCase().replace(/ё/g,'е'); const norm=s=>String(s).toLowerCase().replace(/ё/g,'е');
const BOT={origin:"https://ai-staff-alpha.vercel.app",agent:"e9cc0f28-aaa5-48d4-a020-ae9633988faf"}; const BOT={origin:"https://ai-staff-alpha.vercel.app",agent:"e9cc0f28-aaa5-48d4-a020-ae9633988faf"};
let dialog,data,loading=false,returnFocus; let dialog,data,loading=false,returnFocus;
const supportUrl=new URL('support-form.js?v=20260921-support-mail',document.currentScript.src);
function loadContactForm(){
if(window.CateriumSupportForm){window.CateriumSupportForm.attach(dialog);return;}
if(document.getElementById('ctSupportFormScript'))return;
const script=document.createElement('script');script.id='ctSupportFormScript';script.src=supportUrl.href;
script.onerror=()=>{script.remove();};document.head.append(script);
}
function render(){ function render(){
if(!data||!dialog)return; if(!data||!dialog)return;
const query=norm(dialog.querySelector('#ctHelpSearch').value).trim(),category=dialog.querySelector('#ctHelpCategory').value; const query=norm(dialog.querySelector('#ctHelpSearch').value).trim(),category=dialog.querySelector('#ctHelpCategory').value;
@ -44,7 +51,7 @@
function ensureDialog(){ function ensureDialog(){
if(dialog)return; if(dialog)return;
dialog=document.createElement('dialog');dialog.id='ctHelpDialog';dialog.setAttribute('aria-labelledby','ctHelpTitle'); dialog=document.createElement('dialog');dialog.id='ctHelpDialog';dialog.setAttribute('aria-labelledby','ctHelpTitle');
dialog.innerHTML=`<div class="ct-help-header"><div><h2 id="ctHelpTitle">Помощь в Caterium</h2><p id="ctHelpEdition">Руководство пользователя и поддержка</p></div><button type="button" id="ctHelpClose" aria-label="Закрыть помощь">×</button></div><div class="ct-help-modes" aria-label="Раздел помощи"><button type="button" id="ctHelpGuideTab" aria-pressed="true">Руководство</button><button type="button" id="ctHelpSupportTab" aria-pressed="false">Поддержка</button></div><div class="ct-help-content"><section id="ctHelpGuide"><div class="ct-help-filters"><label>Что хотите сделать?<input id="ctHelpSearch" type="search" placeholder="Например: оплата, PDF, склад" maxlength="240" autocomplete="off"></label><label>Категория<select id="ctHelpCategory"><option value="">Все темы</option></select></label><button type="button" id="ctHelpReset">Все инструкции</button></div><p id="ctHelpStatus" role="status"></p><button type="button" id="ctHelpRetry" hidden>Повторить загрузку</button><div id="ctHelpResults"></div></section><section id="ctHelpSupport" hidden><span class="ct-help-badge">ИИ-помощник</span><h3>Ответы по работе с приложением</h3><p>Помощник отвечает на вопросы «как это сделать» по руководству Caterium. Он не видит данные вашей компании, но ваш вопрос обрабатывает внешний сервис, поэтому не пишите пароли, коды подтверждения и данные клиентов. Поиск по руководству остаётся на этом устройстве и ничего не отправляет.</p><button type="button" id="ctHelpBotStart" class="ct-help-bot-start">Задать вопрос помощнику</button><div id="ctHelpBot" class="ct-help-bot" hidden></div><div class="ct-help-prompts"><button type="button" data-help-query="создать заказ">Как создать заказ?</button><button type="button" data-help-query="оплата">Как отметить оплату?</button><button type="button" data-help-query="закупки">Как рассчитать закупки?</button><button type="button" data-help-query="синхронизация">Не загружается база</button></div><h3>Если инструкция не помогла</h3><p>Подготовьте название раздела, последовательность действий, точный текст ошибки, устройство и браузер. На снимке экрана закройте лишние телефоны и адреса. Не передавайте пароли и коды подтверждения.</p><button type="button" data-help-query="обращение">Памятка для обращения</button></section></div>`; dialog.innerHTML=`<div class="ct-help-header"><div><h2 id="ctHelpTitle">Помощь в Caterium</h2><p id="ctHelpEdition">Руководство пользователя и поддержка</p></div><button type="button" id="ctHelpClose" aria-label="Закрыть помощь">×</button></div><div class="ct-help-modes" aria-label="Раздел помощи"><button type="button" id="ctHelpGuideTab" aria-pressed="true">Руководство</button><button type="button" id="ctHelpSupportTab" aria-pressed="false">Поддержка</button></div><div class="ct-help-content"><section id="ctHelpGuide"><div class="ct-help-filters"><label>Что хотите сделать?<input id="ctHelpSearch" type="search" placeholder="Например: оплата, PDF, склад" maxlength="240" autocomplete="off"></label><label>Категория<select id="ctHelpCategory"><option value="">Все темы</option></select></label><button type="button" id="ctHelpReset">Все инструкции</button></div><p id="ctHelpStatus" role="status"></p><button type="button" id="ctHelpRetry" hidden>Повторить загрузку</button><div id="ctHelpResults"></div></section><section id="ctHelpSupport" hidden><span class="ct-help-badge">ИИ-помощник</span><h3>Ответы по работе с приложением</h3><p>Помощник отвечает на вопросы «как это сделать» по руководству Caterium. Он не видит данные вашей компании, но ваш вопрос обрабатывает внешний сервис, поэтому не пишите пароли, коды подтверждения и данные клиентов. Поиск по руководству остаётся на этом устройстве и ничего не отправляет.</p><button type="button" id="ctHelpBotStart" class="ct-help-bot-start">Задать вопрос помощнику</button><div id="ctHelpBot" class="ct-help-bot" hidden></div><div class="ct-help-prompts"><button type="button" data-help-query="создать заказ">Как создать заказ?</button><button type="button" data-help-query="оплата">Как отметить оплату?</button><button type="button" data-help-query="закупки">Как рассчитать закупки?</button><button type="button" data-help-query="синхронизация">Не загружается база</button></div><h3>Если инструкция не помогла</h3><p>Почта поддержки: <a href="mailto:support@katerion.ru">support@katerion.ru</a>. Вкладка «Написать в поддержку» открывает форму обращения.</p><p>Подготовьте название раздела, последовательность действий, точный текст ошибки, устройство и браузер. На снимке экрана закройте лишние телефоны и адреса. Не передавайте пароли и коды подтверждения.</p><button type="button" data-help-query="обращение">Памятка для обращения</button></section></div>`;
document.body.appendChild(dialog); document.body.appendChild(dialog);
dialog.querySelector('#ctHelpClose').onclick=()=>dialog.close(); dialog.querySelector('#ctHelpClose').onclick=()=>dialog.close();
dialog.addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();e.stopPropagation();dialog.close();}}); dialog.addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();e.stopPropagation();dialog.close();}});
@ -57,7 +64,7 @@
const a=e.target.closest('[data-help-article]');if(a&&data){showGuide();const target=[...dialog.querySelectorAll('details')].find(x=>x.dataset.article===a.dataset.helpArticle);if(target){target.open=true;target.scrollIntoView({block:'nearest'});target.querySelector('summary').focus();}} const a=e.target.closest('[data-help-article]');if(a&&data){showGuide();const target=[...dialog.querySelectorAll('details')].find(x=>x.dataset.article===a.dataset.helpArticle);if(target){target.open=true;target.scrollIntoView({block:'nearest'});target.querySelector('summary').focus();}}
}); });
} }
function open(trigger){ensureDialog();returnFocus=trigger?.currentTarget||trigger||document.activeElement;switchMode(false);if(!dialog.open)dialog.showModal();load();dialog.querySelector('#ctHelpSearch').focus();} function open(trigger){ensureDialog();loadContactForm();dialog.querySelector('#ctContactSection')?.setAttribute('hidden','');dialog.querySelector('#ctContactTab')?.setAttribute('aria-pressed','false');returnFocus=trigger?.currentTarget||trigger||document.activeElement;switchMode(false);if(!dialog.open)dialog.showModal();load();dialog.querySelector('#ctHelpSearch').focus();}
function mount(){ function mount(){
const nav=document.querySelector('header nav'); const nav=document.querySelector('header nav');
if(nav&&!document.getElementById('ctHelpNav')){const b=document.createElement('button');b.id='ctHelpNav';b.type='button';b.dataset.navLabel='Помощь';b.textContent='Помощь';b.onclick=open;nav.appendChild(b);window.sunRegisterNavButton?.({button:b,group:'management',navLabel:'Помощь'});} if(nav&&!document.getElementById('ctHelpNav')){const b=document.createElement('button');b.id='ctHelpNav';b.type='button';b.dataset.navLabel='Помощь';b.textContent='Помощь';b.onclick=open;nav.appendChild(b);window.sunRegisterNavButton?.({button:b,group:'management',navLabel:'Помощь'});}

View File

@ -0,0 +1,87 @@
/* Human support: explicit submit only. Do not serialize the SDK or application state. */
(()=>{
'use strict';
if(window.CateriumSupportForm)return;
const EMAIL='support@katerion.ru',ENDPOINT=new URL('../api/support.php',document.currentScript.src).href;
const identity=()=>`${window.SunCloudV2?.getSession?.()?.user?.id||''}:${window.SunCloudV2?.getWorkspace?.()?.id||''}`;
let root,form,tab,csrf='',busy=false,controller=null,scope=identity(),generation=0,lastPayload='',requestId='';
const $=id=>root?.querySelector('#'+id);
const uuid=()=>typeof crypto.randomUUID==='function'?crypto.randomUUID():[4,2,2,2,6].map(n=>Array.from(crypto.getRandomValues(new Uint8Array(n)),v=>v.toString(16).padStart(2,'0')).join('')).join('-');
function status(message,error=false){$('ctContactStatus').textContent=message;$('ctContactStatus').setAttribute('role',error?'alert':'status');$('ctContactStatus').classList.toggle('ct-contact-error',error);}
function show(){
if(!root)return;
$('ctHelpGuide').hidden=true;$('ctHelpSupport').hidden=true;$('ctContactSection').hidden=false;
$('ctHelpGuideTab').setAttribute('aria-pressed','false');$('ctHelpSupportTab').setAttribute('aria-pressed','false');tab.setAttribute('aria-pressed','true');
root.querySelector('.ct-help-content').scrollTop=0;
const user=window.SunCloudV2?.getSession?.()?.user;
if(!form.dataset.prefilled){$('ctContactEmail').value=typeof user?.email==='string'?user.email:'';$('ctContactName').value=typeof user?.user_metadata?.name==='string'?user.user_metadata.name:'';form.dataset.prefilled='1';}
$('ctContactName').focus({preventScroll:true});
}
async function request(options={}){
const activeController=new AbortController();controller=activeController;const timer=setTimeout(()=>activeController.abort(),20000);
try{
const response=await fetch(ENDPOINT,{credentials:'same-origin',cache:'no-store',signal:activeController.signal,...options});
let value;try{value=await response.json();}catch(_){throw new Error('Сервер не подтвердил отправку. Текст остаётся в форме.');}
if(!response.ok){if(response.status===403)csrf='';throw new Error(typeof value.message==='string'?value.message:'Не удалось отправить сообщение.');}
return value;
}finally{clearTimeout(timer);if(controller===activeController)controller=null;}
}
async function send(event){
event.preventDefault();if(busy||!form.reportValidity())return;
const ticket=generation,who=identity();busy=true;form.setAttribute('aria-busy','true');
const payload={name:$('ctContactName').value.trim(),email:$('ctContactEmail').value.trim(),topic:$('ctContactTopic').value,subject:$('ctContactSubject').value.trim(),message:$('ctContactMessage').value.trim(),website:$('ctContactWebsite').value};
if($('ctContactDiagnostics').checked){
payload.device=String(navigator.userAgent).slice(0,600)+`; экран ${innerWidth} × ${innerHeight}`;
payload.section=String(document.querySelector('header nav button.on')?.textContent||'Вход').trim().slice(0,100);
payload.release=String(window.SunPerformance?.VERSION||'').slice(0,60);
}
const fingerprint=JSON.stringify(payload);if(fingerprint!==lastPayload||!requestId){lastPayload=fingerprint;requestId=uuid();}
status('Отправляю обращение…');form.querySelectorAll('input,textarea,select,button').forEach(e=>e.disabled=true);
try{
if(!csrf){const setup=await request();if(typeof setup.csrf!=='string'||setup.recipient!==EMAIL)throw new Error('Не удалось подготовить защищённую отправку.');csrf=setup.csrf;}
if(ticket!==generation||who!==identity())return;
const answer=await request({method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...payload,csrf,request_id:requestId})});
if(ticket!==generation||who!==identity())return;
if(answer.ok!==true||answer.status!=='accepted'||!/^SUP-[0-9]{8}-[A-F0-9]{12}$/.test(answer.id||''))throw new Error('Результат отправки не подтверждён. Текст остаётся в форме.');
status(`Обращение ${answer.id} принято почтовым сервером для отправки на ${EMAIL}. Ответ поддержки придёт на ${payload.email}.`);
$('ctContactMessage').value='';$('ctContactSubject').value='';lastPayload='';requestId='';
}catch(error){
if(ticket===generation&&who===identity())status(error.name==='AbortError'?'Не удалось дождаться подтверждения. Текст сохранён в форме. Повторная отправка этого же сообщения не создаст дубликат в течение 48 часов.':String(error.message||'Не удалось отправить сообщение.'),true);
}finally{
if(ticket===generation){busy=false;form.setAttribute('aria-busy','false');form.querySelectorAll('input,textarea,select,button').forEach(e=>e.disabled=false);}
}
}
function attach(dialog){
if(root===dialog)return;
root=dialog;scope=identity();
const style=document.createElement('style');style.textContent=`
#ctHelpDialog .ct-help-modes{flex-wrap:wrap}#ctHelpDialog #ctContactTab{font-weight:700}
#ctHelpDialog .ct-contact-cta{padding:16px;border:1px solid #d1c49d;border-radius:12px;background:#f8f4e7;margin:18px 0}
#ctHelpDialog .ct-contact-cta p{margin:0 0 10px}#ctHelpDialog .ct-contact-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
#ctHelpDialog .ct-contact-wide{grid-column:1/-1}#ctHelpDialog #ctContactForm textarea{font:16px/1.5 Arial,sans-serif;resize:vertical;width:100%;min-height:150px;padding:12px;border:1px solid #cdd5cb;border-radius:10px;background:#fff;color:#263d31}
#ctHelpDialog #ctContactForm textarea:focus-visible{outline:3px solid #b39b47;outline-offset:2px}#ctHelpDialog .ct-contact-check{display:flex;flex-direction:row;align-items:flex-start;font-size:13px;gap:8px}
#ctHelpDialog .ct-contact-check input{width:18px;height:18px;min-height:18px;flex:0 0 18px;margin:2px 0 0}
#ctHelpDialog .ct-contact-note{font-size:12px;color:#617166}#ctHelpDialog #ctContactSend{background:#304f3d;color:#fff;justify-self:start}
#ctHelpDialog [disabled]{cursor:wait;opacity:.65}#ctHelpDialog .ct-contact-error{color:#9e3028}#ctHelpDialog #ctContactStatus:empty{display:none}
#ctHelpDialog .ct-contact-hp{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}#ctHelpDialog #ctContactStatus{overflow-wrap:anywhere}
@media(max-width:600px){#ctHelpDialog .ct-contact-grid{grid-template-columns:minmax(0,1fr)}#ctHelpDialog #ctContactSend{width:100%}}
`;document.head.append(style);
tab=document.createElement('button');tab.id='ctContactTab';tab.type='button';tab.textContent='Написать в поддержку';tab.setAttribute('aria-pressed','false');root.querySelector('.ct-help-modes').append(tab);tab.onclick=show;
const section=document.createElement('section');section.id='ctContactSection';section.hidden=true;
section.innerHTML=`<h3>Не нашли ответ или возникла проблема?</h3><p>Опишите вопрос — обращение будет направлено на <a href="mailto:${EMAIL}">${EMAIL}</a>. Для ответа укажите свою почту.</p><form id="ctContactForm"><div class="ct-contact-grid"><label>Ваше имя<input id="ctContactName" name="name" autocomplete="name" maxlength="60" required></label><label>Ваш email для ответа<input id="ctContactEmail" name="email" type="email" autocomplete="email" maxlength="254" required></label><label>Что случилось?<select id="ctContactTopic" name="topic"><option value="question">Вопрос по приложению</option><option value="problem">Ошибка или проблема</option><option value="access">Вход и подписка</option><option value="suggestion">Предложение</option><option value="other">Другое</option></select></label><label>Тема обращения<input id="ctContactSubject" name="subject" maxlength="90" required placeholder="Например: не получается открыть PDF"></label><label class="ct-contact-wide">Ваш вопрос или описание проблемы<textarea id="ctContactMessage" name="message" rows="6" maxlength="4000" required placeholder="Что вы хотели сделать? Что произошло? Какие действия уже пробовали?"></textarea></label><label class="ct-contact-check ct-contact-wide"><input id="ctContactDiagnostics" type="checkbox"><span>Добавить сведения об устройстве: браузер, размер экрана, открытый раздел и версия приложения. Данные заказов и клиентов не отправляются.</span></label><div class="ct-contact-hp" aria-hidden="true"><label>Не заполняйте<input id="ctContactWebsite" name="website" tabindex="-1" autocomplete="off"></label></div><p class="ct-contact-note ct-contact-wide">Не указывайте пароли, коды подтверждения, секретные ключи и лишние персональные данные. При нажатии «Отправить» имя, email и сообщение передаются службе поддержки. Форма не открывает почтовое приложение.</p><button id="ctContactSend" class="ct-contact-wide" type="submit">Отправить в поддержку</button></div></form><p id="ctContactStatus" role="status" aria-live="polite"></p><p class="ct-contact-note">Если отправка через форму недоступна, напишите напрямую: <a href="mailto:${EMAIL}">${EMAIL}</a>.</p>`;
root.querySelector('.ct-help-content').append(section);form=$('ctContactForm');form.addEventListener('submit',send);
for(const id of ['ctHelpGuide','ctHelpSupport']){
const cta=document.createElement('div');cta.className='ct-contact-cta';cta.innerHTML='<p><b>Не нашли ответ? Мы поможем разобраться.</b></p><button type="button" data-human-support>Написать в поддержку</button>';
$(id).append(cta);cta.querySelector('button').onclick=show;
}
root.addEventListener('click',event=>{if(event.target.closest('#ctHelpGuideTab,#ctHelpSupportTab')){section.hidden=true;tab.setAttribute('aria-pressed','false');}});
}
function resetIfChanged(){
const next=identity();if(next===scope)return;scope=next;generation++;controller?.abort();csrf='';lastPayload='';requestId='';busy=false;
if(form){form.reset();delete form.dataset.prefilled;form.setAttribute('aria-busy','false');form.querySelectorAll('[disabled]').forEach(e=>e.disabled=false);status('');}
}
window.addEventListener('sun:cloud-permissions-changed',resetIfChanged);
window.addEventListener('sun:cloud-tenant-changing',()=>{scope='';resetIfChanged();});
window.CateriumSupportForm=Object.freeze({attach});
const existing=document.getElementById('ctHelpDialog');if(existing)attach(existing);
})();

View File

@ -1,6 +1,7 @@
const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session-mfa-recovery-promo-entry'; const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session-mfa-recovery-promo-entry-support-mail';
const VERSION='20260918-ui-stability'; const VERSION='20260918-ui-stability';
const CORE=[ const CORE=[
'./core/support-form.js?v=20260921-support-mail',
'./core/trial-promo-developer-v181.js?v=20260921-promo-entry', './core/trial-promo-developer-v181.js?v=20260921-promo-entry',
'./core/banquet-client-menu.js?v=20260920-onepage', './core/banquet-client-menu.js?v=20260920-onepage',
'./core/training-catalog.js?v=20260920-training', './core/training-catalog.js?v=20260920-training',
@ -15,6 +16,7 @@ const CORE=[
'./offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg' './offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg'
]; ];
const CRITICAL_FRESH=new Set([ const CRITICAL_FRESH=new Set([
'/core/support-form.js',
'/core/trial-promo-developer-v181.js','/core/hotfix-v1763.js', '/core/trial-promo-developer-v181.js','/core/hotfix-v1763.js',
'/core/banquet-client-menu.js', '/core/banquet-client-menu.js',
'/core/training-catalog.js', '/core/training-catalog.js',

View File

@ -20,7 +20,7 @@ test('manual search, categories and related instructions work without sending co
await page.locator('[data-article="payment"] [data-help-article="delivery"]').click();await expect(page.locator('[data-article="delivery"]')).toHaveAttribute('open',''); await page.locator('[data-article="payment"] [data-help-article="delivery"]').click();await expect(page.locator('[data-article="delivery"]')).toHaveAttribute('open','');
await page.getByRole('combobox',{name:'Категория',exact:true}).selectOption('Меню и ТТК');await expect(page.locator('#ctHelpResults details')).toHaveCount(3); await page.getByRole('combobox',{name:'Категория',exact:true}).selectOption('Меню и ТТК');await expect(page.locator('#ctHelpResults details')).toHaveCount(3);
await page.getByRole('searchbox').fill('<img src=x onerror=alert(1)>');await expect(page.locator('#ctHelpStatus')).toContainText('Ничего не найдено'); await page.getByRole('searchbox').fill('<img src=x onerror=alert(1)>');await expect(page.locator('#ctHelpStatus')).toContainText('Ничего не найдено');
expect(requests.every(u=>u.includes('/help/knowledge-v1.json'))).toBe(true); expect(requests.every(u=>['/help/knowledge-v1.json','/core/support-form.js'].includes(new URL(u).pathname))).toBe(true);
expect(await page.evaluate(()=>localStorage.sunOrders)).toBe('private-order-canary'); expect(await page.evaluate(()=>localStorage.sunOrders)).toBe('private-order-canary');
}); });

View File

@ -2,13 +2,13 @@ import { defineConfig, devices } from '@playwright/test';
import {fileURLToPath} from 'node:url'; import {fileURLToPath} from 'node:url';
export default defineConfig({ export default defineConfig({
testDir:'.', testDir:'.',
testMatch:['promo-entry.spec.mjs','developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'], testMatch:['support-form.spec.mjs','promo-entry.spec.mjs','developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'],
timeout:30000, timeout:30000,
use:{baseURL:'http://127.0.0.1:4173'}, use:{baseURL:'http://127.0.0.1:4173'},
webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true}, webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true},
projects:[ projects:[
{name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}}, {name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}},
{name:'iphone-webkit',testMatch:['promo-entry.spec.mjs','developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, {name:'iphone-webkit',testMatch:['support-form.spec.mjs','promo-entry.spec.mjs','developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}},
{name:'desktop',use:{...devices['Desktop Chrome']}}, {name:'desktop',use:{...devices['Desktop Chrome']}},
{name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}} {name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}}
] ]

View File

@ -0,0 +1,38 @@
import {chromium,expect} from '@playwright/test';
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';
const base=new URL(process.env.TIMEWEB_BASE_URL||'https://app.caterium.ru');
assert.equal(base.href,'https://app.caterium.ru/');
const output='production-ui-results';await fs.mkdir(output,{recursive:true});
const browser=await chromium.launch(),results=[];
try{
for(const width of [390,1440]){
const context=await browser.newContext({viewport:{width,height:900},serviceWorkers:'block'});
try{
const page=await context.newPage();
await page.route('**/*',r=>{const u=new URL(r.request().url());return r.request().method()==='GET'&&u.origin===base.origin&&!u.pathname.startsWith('/api/')?r.continue():r.abort();});
await page.goto(base.href,{waitUntil:'domcontentloaded'});
await page.getByRole('button',{name:'Помощь со входом',exact:true}).click();
await page.locator('#ctContactTab').click();await expect(page.locator('#ctContactForm')).toBeVisible();
await expect(page.locator('#ctContactSection')).toContainText('support@katerion.ru');
assert(await page.locator('#ctContactForm').evaluate(el=>el.scrollWidth<=el.clientWidth));
await page.screenshot({path:`${output}/support-form-${width}.png`,animations:'disabled'});
results.push({width,form:true,recipient:'support@katerion.ru',draftOnly:true});
}finally{await context.close();}
}
const context=await browser.newContext();
try{
for(const path of ['core/help-center.js','core/support-form.js']){
const response=await context.request.get(new URL(path+'?verification='+Date.now(),base).href,{headers:{'Cache-Control':'no-cache'}});assert.equal(response.status(),200);
const local=await fs.readFile('public/'+path);assert.equal(createHash('sha256').update(await response.body()).digest('hex'),createHash('sha256').update(local).digest('hex'));
}
// No real message is sent. Check the live PHP/session contract and that a
// cross-origin request cannot reach mail(). Inbox delivery is a separate check.
const response=await context.request.get(new URL('api/support.php',base).href);
assert.equal(response.status(),200);assert.match(response.headers()['cache-control'],/no-store/);
const data=await response.json();assert.equal(data.recipient,'support@katerion.ru');assert.match(data.csrf,/^[a-f0-9]{64}$/);
const rejected=await context.request.post(new URL('api/support.php',base).href,{headers:{Origin:'https://example.invalid'},data:{}});assert.equal(rejected.status(),403);
await fs.writeFile(`${output}/support-form.json`,JSON.stringify({checkedAt:new Date().toISOString(),results,phpSessionEndpoint:true,crossOriginBlocked:true,realEmailSent:false,inboxDeliveryVerified:false},null,2));
}finally{await context.close();}
}finally{await browser.close();}

View File

@ -0,0 +1,54 @@
import {test,expect} from '@playwright/test';
import fs from 'node:fs';
import {spawnSync} from 'node:child_process';
const EMAIL='support@katerion.ru';
async function fixture(page){
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><header><nav></nav></header></body></html>'}));
await page.goto('/index.html');await page.addStyleTag({url:'/core/help-center.css'});await page.addScriptTag({url:'/core/help-center.js'});
await page.getByRole('button',{name:'Помощь',exact:true}).click();await expect(page.locator('#ctContactTab')).toBeVisible();
}
async function fill(page){
await page.locator('#ctContactTab').click();await page.locator('#ctContactName').fill('Анна');await page.locator('#ctContactEmail').fill('anna@example.invalid');
await page.locator('#ctContactSubject').fill('Не получается открыть PDF');await page.locator('#ctContactMessage').fill('Как посмотреть предложение с телефона?');
}
function mock(page,answer={ok:true,status:'accepted',id:'SUP-20260921-123456ABCDEF'},http=202){
const requests=[];
return page.route('**/api/support.php',r=>{requests.push({method:r.request().method(),body:r.request().postDataJSON()});return r.fulfill({status:r.request().method()==='GET'?200:http,contentType:'application/json',body:JSON.stringify(r.request().method()==='GET'?{csrf:'token',recipient:EMAIL}:answer)});}).then(()=>requests);
}
test('PHP support delivery validates inputs and never sends a real test email',async()=>{
const result=spawnSync('php',['tests/support-mail.php'],{encoding:'utf8'});expect(result.status,result.stderr+result.stdout).toBe(0);
});
test('help exposes contact form after an unanswered search, without background mail or personal data requests',async({page})=>{
const requests=await mock(page);await fixture(page);
await page.locator('#ctHelpSearch').fill('zznomatch99381zz');await expect(page.locator('#ctHelpStatus')).toContainText('Ничего не найдено');
await page.locator('#ctHelpGuide [data-human-support]').click();await expect(page.locator('#ctContactForm')).toBeVisible();
expect(requests).toHaveLength(0);await expect(page.locator('#ctContactSection')).toContainText(EMAIL);
await page.locator('#ctHelpGuideTab').click();await expect(page.locator('#ctContactForm')).toBeHidden();await expect(page.locator('#ctHelpSearch')).toBeVisible();
});
test('explicit form submission sends the fixed-recipient contract and whitelisted diagnostics only',async({page},info)=>{
const requests=await mock(page);await fixture(page);
await page.evaluate(()=>{localStorage.sunOrders='PRIVATE_ORDER_CANARY';const sdk={token:'SECRET_TOKEN_CANARY'};sdk.self=sdk;window.SunCloudV2={getClient:()=>sdk};});
await fill(page);await page.locator('#ctContactDiagnostics').check();await page.screenshot({path:info.outputPath('support-form.png')});
expect(await page.locator('#ctContactForm').evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true);
await page.locator('#ctContactSend').click();await expect(page.locator('#ctContactStatus')).toContainText('принято почтовым сервером');
const post=requests.find(r=>r.method==='POST').body;expect(post.email).toBe('anna@example.invalid');expect(post.message).toContain('предложение');
expect(post.device).toContain('экран');expect(post).not.toHaveProperty('to');expect(post).not.toHaveProperty('token');expect(JSON.stringify(post)).not.toMatch(/PRIVATE_ORDER_CANARY|SECRET_TOKEN_CANARY/);
await expect(page.locator('#ctContactMessage')).toHaveValue('');expect(await page.evaluate(()=>localStorage.sunOrders)).toBe('PRIVATE_ORDER_CANARY');
});
test('failed submission preserves the form and request ID; no silent mail-client fallback or false success',async({page})=>{
const requests=await mock(page,{ok:false,message:'Почтовый сервер не принял сообщение'},503);await fixture(page);await fill(page);
await page.locator('#ctContactSend').click();await expect(page.locator('#ctContactStatus')).toHaveAttribute('role','alert');await expect(page.locator('#ctContactMessage')).toHaveValue('Как посмотреть предложение с телефона?');
await page.locator('#ctContactSend').click();await expect.poll(()=>requests.filter(r=>r.method==='POST').length).toBe(2);
const posts=requests.filter(r=>r.method==='POST').map(r=>r.body);expect(posts[0].request_id).toBe(posts[1].request_id);expect(posts[0]).not.toHaveProperty('device');
await page.locator('#ctHelpClose').click();await page.getByRole('button',{name:'Помощь',exact:true}).click();await page.locator('#ctContactTab').click();await expect(page.locator('#ctContactMessage')).toHaveValue('Как посмотреть предложение с телефона?');
});
test('invalid fields never send and another account never sees the previous support draft',async({page})=>{
const requests=await mock(page);await fixture(page);await fill(page);await page.locator('#ctContactEmail').fill('invalid');await page.locator('#ctContactSend').click();expect(requests).toHaveLength(0);
await page.evaluate(()=>{window.SunCloudV2={getSession:()=>({user:{id:'another',email:'other@example.invalid'}})};window.dispatchEvent(new Event('sun:cloud-permissions-changed'));});
await page.getByRole('button',{name:'Помощь',exact:true}).click();await page.locator('#ctContactTab').click();await expect(page.locator('#ctContactMessage')).toHaveValue('');await expect(page.locator('#ctContactEmail')).toHaveValue('other@example.invalid');
});

30
tests/support-mail.php Normal file
View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Caterium\Support;
function mail($to,$subject,$body,$headers,$params): bool { $GLOBALS['capturedMail']=func_get_args();return true; }
require __DIR__.'/../public/api/support-lib.php';
function check(bool $condition,string $message):void{if(!$condition)throw new \RuntimeException($message);}
function denies(callable $f,int $status):void{try{$f();}catch(Problem $e){check($e->status===$status,'Expected HTTP '.$status.', got '.$e->status);return;}throw new \RuntimeException('Expected rejection '.$status);}
$dir=sys_get_temp_dir().'/caterium-support-test-'.bin2hex(random_bytes(6));mkdir($dir,0700);
$input=['name'=>'Тест','email'=>'learner@example.invalid','topic'=>'question','subject'=>'Вопрос по меню','message'=>'Как создать новое предложение?','request_id'=>'10000000-0000-4000-8000-000000000001','website'=>''];
try{
$row=validate($input);check($row['name']==='Тест','Unicode preserved');
foreach([
['email'=>"test@example.com\r\nBcc: stranger@example.com"],['topic'=>'unknown'],['message'=>''],['subject'=>"New\nheader"],['name'=>['invalid']],['message'=>str_repeat('x',16001)],['website'=>'bot-filled'],['to'=>'someone@example.com'],['csrf'=>['invalid']]
] as $bad){if(isset($bad['csrf']))continue;denies(static function()use($input,$bad){validate(array_merge($input,$bad));},422);}
$count=0;$send=static function($row,$id)use(&$count){$count++;return deliver($row,$id);};
$answer=submit($row,'127.0.0.1',$dir,$send,1000000);check($answer['status']==='accepted','Mail accepted');
$repeat=submit($row,'127.0.0.2',$dir,$send,1000001);check($repeat===$answer&&$count===1,'Retries must not send duplicates');
$changed=$row;$changed['message']='Changed';denies(static function()use($changed,$dir,$send){submit($changed,'127.0.0.1',$dir,$send,1000002);},409);
[$to,$subject,$body,$headers,$params]=$GLOBALS['capturedMail'];check($to==='support@katerion.ru','Recipient exactly matches user request');check($headers['Reply-To']===$input['email'],'Reply to sender');check($params==='-fno-reply@caterium.ru','Fixed envelope');
check(strpos(base64_decode($body),$input['message'])!==false,'UTF-8 message is intact');check(strpos($subject,'SUP-')!==false,'Request ID in subject');
$saved=file_get_contents($dir.'/limits.json');check(strpos($saved,$input['email'])===false&&strpos($saved,$input['message'])===false,'Do not store message/email in rate-limit file');
for($i=2;$i<=3;$i++){$next=$row;$next['request_id']=sprintf('10000000-0000-4000-8000-%012d',$i);submit($next,'127.0.0.1',$dir,$send,1000000+$i);}
$next['request_id']='10000000-0000-4000-8000-000000000004';denies(static function()use($next,$dir,$send){submit($next,'127.0.0.3',$dir,$send,1000010);},429);
$failure=$row;$failure['email']='other@example.invalid';$failure['request_id']='20000000-0000-4000-8000-000000000001';
denies(static function()use($failure,$dir){submit($failure,'127.0.0.4',$dir,static function(){return false;},1000020);},503);
$uncertain=$row;$uncertain['email']='uncertain@example.invalid';$uncertain['request_id']='30000000-0000-4000-8000-000000000001';
try{submit($uncertain,'127.0.0.5',$dir,static function(){throw new \RuntimeException('transport stopped');},1000030);}catch(\RuntimeException $e){}
denies(static function()use($uncertain,$dir,$send){submit($uncertain,'127.0.0.5',$dir,$send,1000031);},409);
echo "PASS support mail: fixed recipient, reply address, validation, Unicode, idempotency, rate limits, rejection and uncertain-state protection; no real email sent.\n";
}finally{foreach(glob($dir.'/*') as $path)unlink($path);rmdir($dir);}