Files
han-app/modules/module-02-frontend-test-site.md
T

20 KiB
Raw Blame History

module-02. Проектная спецификация тестового frontend-сайта

Статус: целевая спецификация реализации MVP; это проектирование, не код.
Канонические источники: README.md, arch-00-glossary.md, arch-01-system-architecture.md, arch-02-api-contracts.md, arch-03-docker-compose-blueprint.md, arch-04-settings-and-content.md, arch-05-agent-development-process.md, module-01-api-backend.md.

1. Назначение и границы

Сайт нужен для ручной, интеграционной и E2E-проверки всех пользовательских сценариев HAN Chat через реальные публичные API. Он остаётся простым по визуальному дизайну, но функционально покрывает guest, OTP/PKCE, bootstrap, UX-сессию, чат, файлы, профиль, realtime и деградации.

Сайт не реализует бизнес-решения backend, не обращается к PostgreSQL, Redis, Bitrix24 или S3 постоянными credentials и не подменяет Message Safety. Публичный вопрос отправляется обычным текстовым сообщением.

2. Зафиксированный стек

  • Expo SDK + React Native + TypeScript, web target через Expo Router.
  • React Query для server state; локальный reducer/state machine для auth и отложенной отправки.
  • expo-auth-session/OIDC Authorization Code Flow with PKCE; парольный flow запрещён.
  • SecureStore на native; для test web — защищённая browser storage adapter с явным предупреждением о риске XSS. Access token предпочтительно держать в памяти, refresh token — в доступном платформе secure storage.
  • React Hook Form + schema validation (Zod либо эквивалент).
  • WebSocket API браузера; REST polling как обязательный fallback.
  • Playwright для web E2E, Vitest/Jest + Testing Library для unit/component.
  • Никакого отдельного frontend nginx: production-статику отдаёт единственный корневой nginx.

3. Предлагаемая структура

frontend-test-site/
  app/
    _layout.tsx
    index.tsx
    auth/callback.tsx
    dialogs/index.tsx
    dialogs/[dialogId].tsx
    profile.tsx
    diagnostics.tsx
  src/
    api/{client,errors,public,auth,dialogs,attachments,profile}.ts
    auth/{oidc,pkce,token-store,refresh-single-flight}.ts
    session/{ux-session,activity}.ts
    realtime/{socket,polling,reconcile}.ts
    flows/{deferred-send,bootstrap}.ts
    components/
    config/
    accessibility/
  tests/{unit,component,contract,e2e}/
  app.config.ts
  package.json

4. Runtime state

Состояние Хранение Правило
access token память не логировать, не показывать полностью
refresh token secure adapter очищать при logout/invalid_grant
PKCE verifier/state/nonce session storage, короткий TTL одноразовые, проверяются callback
ux_session_id, last_activity_at только память не localStorage
guest_session_id локально, опционально не auth, не посылается как право доступа
pending message/file intent память восстанавливает отправку после OTP
REST cursors память по dialog opaque, не парсить

Auth state machine: guest → authorizing → bootstrapping → authenticated; при refresh failure — обратно guest. UX-сессия независима от Keycloak-сессии.

5. Экраны

5.1. Главная

  • загрузка GET /api/v1/public/app-config и /content;
  • приветствие, популярные вопросы, textarea, attach button, send;
  • индикаторы загрузки/ошибки и повтор;
  • ссылки на чат и профиль (при guest запускают auth только по явному действию);
  • диагностический badge режима: guest/authenticated, WS/polling, без раскрытия token.

Выбор популярного вопроса сразу запускает тот же send flow, что ручной текст.

5.2. Согласия и OTP

Modal согласий отображает актуальные URL/версии из config. personal_data и user_agreement обязательны, marketing необязателен. После подтверждения intent остаётся в памяти, начинается OIDC PKCE redirect.

OTP вводится на странице/теме Keycloak. В MVP Keycloak сверяет mock-код из env; frontend не хранит и не проверяет код. Для тестовой среды UI может показывать только текст «используется тестовый OTP», но не получать secret из API.

5.3. Чат

  • в MVP frontend показывает пользователю один чат с компанией без истории диалогов;
  • пункт навигации «Чат» вызывает POST /dialogs: backend возвращает текущий активный диалог или создаёт новый, после чего frontend открывает /dialogs/{dialog_id};
  • backend-модель диалогов и GET /dialogs сохраняются для будущего возврата истории;
  • экран чата содержит статус, сообщения, composer и attachment;
  • сообщения сортируются по created_at asc, дубли объединяются по message_id;
  • waiting_for_company, waiting_for_client, closed отображаются русскими подписями;
  • завершённая беседа readonly; CTA «Продолжить общение» повторно открывает текущий чат через POST /dialogs;
  • промежуточный safety 203 клиенту не показывается: send request остаётся в progress до финального ответа.

5.4. Профиль

Readonly блок «Личные данные» из GET /me; блок «Документы» из /me/documents, допускается пустой. Редактирование отсутствует. Для изменения данных — CTA в чат. Download URL запрашивается только после клика и не сохраняется.

5.5. Diagnostics (только non-production)

Последние безопасные request id, HTTP status, WS state, cursor, время token expiry и UX-session id. Tokens, OTP, PII, тела сообщений и presigned URL не выводятся.

6. Startup, auth и bootstrap

  1. Немедленно показать guest UI и параллельно загрузить public config/content.
  2. Проверить refresh token. При наличии — выполнить silent Refresh Token Grant через single-flight.
  3. При успехе определить новую UX-сессию (cold_start при новом page lifecycle), вызвать session-start, затем загрузить profile/dialogs.
  4. При отсутствии/истечении refresh token оставаться guest до protected action.
  5. После OTP callback проверить state/nonce, обменять code с PKCE, вызвать POST /auth/bootstrap с согласиями и device metadata.
  6. Создать UX-сессию, если её нет; затем продолжить pending intent.

Bootstrap повторяем безопасно после неопределённого сетевого результата. Телефон в body никогда не передаётся.

7. UX-сессия

  • ux_session_id и activity timestamp живут только в памяти вкладки.
  • После JWT session-start вызывается с first_launch, cold_start либо idle_timeout.
  • Idle timeout берётся из app-config; default UI не подменяет server config.
  • Visibility/focus/user input обновляют activity; возврат после превышения timeout создаёт новую сессию.
  • Refresh token grant не создаёт новую UX-сессию.
  • X-Ux-Session-Id добавляется ко всем JWT REST-запросам, когда id уже получен.

8. HTTP client и заголовки

Каждый API-запрос получает X-Request-ID (UUID), traceparent при активной трассировке и Accept: application/json. Protected request получает Bearer token и X-Ux-Session-Id.

Idempotency-Key обязателен для POST /dialogs и POST .../messages; ключ создаётся один раз на пользовательское действие и сохраняется на retry этого действия. Новый intent получает новый key. Для attachment init/complete повтор соблюдает state/idempotency контракта backend.

Единый error envelope маппится по error.code, а request_id показывается в деталях поддержки. Тело/headers с credentials не логируются.

9. Token refresh single-flight

  • планировать refresh за 60 секунд до exp;
  • один Promise/mutex на refresh; все параллельные запросы ждут его;
  • на первом 401 unauthorized/token_expired — один refresh и один replay исходного запроса;
  • mutating replay использует исходный Idempotency-Key;
  • второй 401 не запускает цикл;
  • invalid_grant очищает tokens, закрывает WS, переводит в guest;
  • WS auth failure использует тот же single-flight, затем reconnect;
  • logout отзывает/завершает OIDC-сессию best effort и всегда очищает local secrets.

10. Отправка текста

  1. Валидировать непустой нормализованный текст и клиентский max length из контракта.
  2. Если guest — сохранить intent, consent → OTP → bootstrap → session-start.
  3. POST /dialogs с idempotency key, сохранить dialog_id.
  4. POST /dialogs/{id}/messages с отдельным key.
  5. Блокировать повторный click только для того же intent; другие действия не замораживать.
  6. На 201 merge MessageResponse; на 422 message_blocked показать безопасный текст без повтора; на 503/504 предложить retry с тем же key.

11. Файловый flow

MVP допускает ровно один файл, только allow-list extension+MIME, до 5 МБ или значений app-config.

  1. Локальная prevalidation.
  2. Создать/reuse dialog.
  3. POST .../attachments/init с filename, MIME, size.
  4. Выполнить прямой PUT upload_url с точно выданными upload_headers; API domain при этом не используется.
  5. Вычислить SHA-256, вызвать complete.
  6. Отправить file message с attachment_id и sha256:<hex>.

Presigned URL не сохраняется и редактируется из диагностик. Abort позволяет отменить PUT; orphan очищает backend. При expiry init выполняется повторно в рамках согласованного состояния. CORS S3 должен разрешать origin сайта, PUT и необходимые headers.

12. Realtime и polling

Предпочтение — WSS /api/v1/realtime, token через согласованный subprotocol; query token допускается только для совместимости и не логируется.

  • connected → subscribe актуальных dialog ids;
  • события message.new, message.status, dialog.status merge идемпотентно;
  • отвечать pong на ping;
  • reconnect 1, 2, 4…30 секунд с jitter;
  • после каждого reconnect делать REST gap reconciliation по последнему cursor;
  • если WS недоступен более 30 секунд — polling GET .../messages?after=...;
  • polling прекращается после устойчивого WS, но только после reconciliation;
  • hidden tab снижает polling frequency без нарушения восстановления;
  • неизвестные event types игнорируются с безопасной метрикой.

13. Ошибки и UX

Ситуация Поведение
offline/network banner, сохранение intent в памяти, ручной retry
400 validation подсветить поле; не retry автоматически
401 single-flight refresh; при провале guest
403 consents обновить config, повторить consent flow
404 безопасное «ресурс недоступен», обновить список
409 idempotency остановить retry, показать request id
422 blocked нейтральное сообщение, контент не отправлен
429 countdown по Retry-After
503/504 зависимость недоступна; retry с тем же key
S3 PUT error оставить attachment intent, предложить повтор
WS failure polling badge, чат остаётся usable

Skeleton/empty/error states обязательны для каждого data screen. Никаких optimistic «delivered» до 201.

14. Конфигурация и сборка

Public build-time env содержит только URL/realm/client id:

EXPO_PUBLIC_API_BASE_URL=https://tohin.ru
EXPO_PUBLIC_AUTH_BASE_URL=https://tohin.ru/auth
EXPO_PUBLIC_KEYCLOAK_REALM=han-chat
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend
EXPO_PUBLIC_APP_ENV=production-like

Redirect URI и allowed origins фиксируются в Keycloak/nginx. Service tokens, S3 keys и mock OTP code во frontend env запрещены. Бизнес-конфиг приходит через /public/app-config, тексты — /public/content.

Production: статический export монтируется в корневой nginx, try_files $uri /index.html; hashed assets immutable, index.html no-cache/revalidate. Local dev: Expo dev server, опциональный proxy корневого nginx через FRONTEND_DEV_PROXY_ENABLED=true.

15. Доступность

  • WCAG 2.1 AA как цель; полная keyboard navigation и видимый focus.
  • Семантические headings/landmarks, labels и error descriptions.
  • Modal: focus trap, возврат focus, Escape только если не нарушает обязательный flow.
  • Live region для новых сообщений и статусов без повторного озвучивания всей ленты.
  • Контраст, zoom 200%, reduced motion, touch targets не менее 44×44 CSS px.
  • Статусы не кодируются одним цветом; файлы имеют доступные имена и progress.
  • OTP поля поддерживают paste/autocomplete, но не логируют значение.

16. Тестовая матрица

Unit/component

  • auth state machine, PKCE callback state/nonce;
  • refresh scheduler/single-flight/concurrent 401;
  • UX idle boundary;
  • idempotency key reuse;
  • error mapping/redaction;
  • text/file union и file validation;
  • WS merge, duplicate, reconnect и poll switch;
  • accessibility scans ключевых экранов.

Contract/integration

  • DTO соответствует api-backend/openapi.yaml;
  • public cache/ETag;
  • bootstrap без phone body;
  • dialog 200/201;
  • message 201/422/429/503/504;
  • presigned PUT headers/checksum/expiry;
  • profile/documents readonly;
  • WS события и REST reconciliation.

E2E

Сценарий Варианты
guest просмотр public content; write закрыт
first send manual/popular → consents → mock OTP → delivered
return valid refresh без OTP; expired refresh с OTP
text safety allow, block, pending-to-final, timeout
file PDF/image allow, deny, wrong MIME/size/checksum, expired URL
realtime message, status, close, reconnect, polling fallback
concurrency два send click, несколько 401, две вкладки
profile filled/null fields, empty documents, download failure
security XSS text, token absence in logs/storage diagnostics

Browsers: Chromium, Firefox, WebKit; viewport desktop/mobile. Реальный Keycloak mock realm и API stub/compose используются в CI.

17. Definition of Done

  • все экраны и flows выше реализованы на русском;
  • guest не вызывает protected write;
  • PKCE/OTP, silent refresh, bootstrap и pending intent проверены E2E;
  • UX-session создаётся и передаётся строго по правилам;
  • idempotency и refresh single-flight выдерживают concurrency;
  • text/file lifecycle работает через presigned PUT;
  • WS и polling не оставляют gap;
  • profile readonly и documents empty state реализованы;
  • CSP/CORS совместимы без unsafe token practices;
  • отсутствуют secrets, PII, message body и URLs в логах;
  • accessibility checks и keyboard сценарии проходят;
  • unit/component/contract/E2E matrix зелёная;
  • production static и dev proxy режимы проверены через единственный nginx.

18. Решения, допущения и TBD

Решения: Expo/TypeScript; server state через React Query; auth state machine; WS best effort + обязательная REST reconciliation; никаких optimistic delivered.

Допущения: test site использует те же API и Keycloak realm contracts, что мобильные клиенты; locale MVP — ru; browser secure storage не эквивалентен OS Keychain, поэтому CSP и отсутствие сторонних scripts обязательны.

TBD:

  • F1: окончательный Keycloak browser adapter и token rotation policy;
  • F2: точные max lengths и WS limits после OpenAPI;
  • F3: web storage policy refresh token перед production security review;
  • F4: окончательный DTO app-config (G10);
  • F5: WS event_id/protocol version (TBD module-01/G11);
  • F6: продуктовые тексты всех error states по мнемоникам.