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:
parent
298b29cd52
commit
c3cb44419b
73
.github/workflows/support-form-check.yml
vendored
Normal file
73
.github/workflows/support-form-check.yml
vendored
Normal 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
|
||||||
12
docs/releases/2026-09-21-SUPPORT-FORM.md
Normal file
12
docs/releases/2026-09-21-SUPPORT-FORM.md
Normal 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.
|
||||||
79
public/api/support-lib.php
Normal file
79
public/api/support-lib.php
Normal 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
43
public/api/support.php
Normal 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);
|
||||||
|
}
|
||||||
@ -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:'Помощь'});}
|
||||||
|
|||||||
87
public/core/support-form.js
Normal file
87
public/core/support-form.js
Normal 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);
|
||||||
|
})();
|
||||||
@ -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',
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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}}
|
||||||
]
|
]
|
||||||
|
|||||||
38
tests/production-support-form.mjs
Normal file
38
tests/production-support-form.mjs
Normal 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();}
|
||||||
54
tests/support-form.spec.mjs
Normal file
54
tests/support-form.spec.mjs
Normal 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
30
tests/support-mail.php
Normal 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);}
|
||||||
Loading…
Reference in New Issue
Block a user