From d528f77b03e6877c9252981f026610bf0ab12fcf Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 23:50:15 +0300 Subject: [PATCH] analyst(ET): auto-commit from analyst run_id=525 --- docs/work-items/ORCH-095/01-brd.md | 154 ++++++++++++++++++ docs/work-items/ORCH-095/02-trz.md | 132 +++++++++++++++ .../ORCH-095/03-acceptance-criteria.md | 97 +++++++++++ docs/work-items/ORCH-095/04-test-plan.yaml | 95 +++++++++++ 4 files changed, 478 insertions(+) create mode 100644 docs/work-items/ORCH-095/01-brd.md create mode 100644 docs/work-items/ORCH-095/02-trz.md create mode 100644 docs/work-items/ORCH-095/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-095/04-test-plan.yaml diff --git a/docs/work-items/ORCH-095/01-brd.md b/docs/work-items/ORCH-095/01-brd.md new file mode 100644 index 0000000..352bccd --- /dev/null +++ b/docs/work-items/ORCH-095/01-brd.md @@ -0,0 +1,154 @@ +--- +work_item: ORCH-095 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 01 — BRD (бизнес-требования): ORCH-095 — HTML-инъекция «<1м» в render_task_tracker застывает live-карточку + +Work Item: **ORCH-095** · Repo: **orchestrator** · Стадия: analysis + +## 1. Бизнес-контекст и проблема + +Live-трекер задачи (`src/notifications.py::render_task_tracker`) — **основной канал +видимости конвейера для оператора**. Слава узнаёт состояние каждой задачи по её единственной +карточке в Telegram (инвариант «одна карточка на задачу», ORCH-042/067/087). Если карточка +перестаёт обновляться — оператор слепнет: задача реально идёт/завершилась, а карточка врёт. + +**Установленный факт (воспроизведён детерминированно 09.06, сырой ответ Telegram).** +Прямой вызов `editMessageText` для застрявшей карточки ORCH-093 (`message_id 18854`) вернул: + +``` +400 Bad Request: can't parse entities: Unsupported start tag "1м" at byte offset 500 +``` + +В тексте карточки на позиции ~379 присутствует подстрока `<1м · …` — длительность стадии +«меньше одной минуты», которую `_fmt_minutes` (`src/notifications.py:288-289`) рендерит как +литерал **`<1м`**. Карточка отправляется с `parse_mode=HTML` (`editMessageText`, +`notifications.py:175`). Telegram трактует `<1м` как **открывающий HTML-тег** → парсинг падает +с `400` → `edit_telegram` возвращает `EDIT_FAILED` → `update_task_tracker` по ветке +`EDIT_FAILED` (`notifications.py:733-739`) делает `return`, **не** отправляя новую карточку +(защита от дублей, ORCH-087) → карточка **застывает** на стейте, где `<1м` впервые попал в текст. + +**Цепочка отказа** (по коду): +`_fmt_minutes(<60s) → "<1м"` → интерполируется в HTML без экранирования → `editMessageText` +`400 can't parse entities` → `edit_telegram → EDIT_FAILED` → `update_task_tracker` ранний +`return` → карточка не обновляется до конца жизни задачи. + +**Почему проявляется не на каждой задаче.** Баг ловится **только** когда хотя бы одна +длительность стадии < 1 мин (`seconds < 60`) и эта строка попадает в текст, который затем +редактируется. Карточки ORCH-090/091 редактировались успешно (на момент `edit` в их тексте +`<1м` не было); ORCH-093 — упала. Это объясняет «плавающую» природу симптома. + +**Корневой класс дефекта — шире одного `<1м`.** Текст карточки собирается с `parse_mode=HTML` +из смеси (а) намеренной разметки-обёртки (`` номер задачи, ``) и (б) подставляемых +**данных**. Намеренная разметка экранироваться **не должна**; данные — должны. Сейчас +экранирован только заголовок (`esc_title`, `notifications.py:428`) и href/label внутри +`plane_issue_link`. Прочие данные — длительности (`_fmt_minutes`), метрики токенов/стоимости +(`fmt_tokens`/`fmt_cost`), имя модели (`short_model_name`), статус-лейбл +(`_card_status_label`) — вставляются **без** `html.escape`. `<1м` — первый сработавший +экземпляр этого класса; задача закрывает класс, а не единичный символ. + +## 2. Объём (scope) + +### В объёме +- Устранить HTML-инъекцию в `render_task_tracker`: любые **данные**, попадающие в текст + карточки с `parse_mode=HTML`, не должны ломать парсер Telegram (`< > &` в данных + безопасны). +- Привести формат «длительность < 1 мин» к HTML-безопасному виду (экранированный `<1м` + ИЛИ переформулировка `<1м` → `~0м` / `< 1 мин` с экранированием). +- Сохранить работоспособность **намеренной** разметки карточки (`` номер задачи, + жирный/прочее форматирование) — экранируются только данные, не обёртка. +- Восстановить обновления уже застрявших карточек (после фикса карточка возобновляет + обновления или переотправляется свежей). +- Юнит-покрытие HTML-безопасности всех динамических полей; зелёный регресс `pytest tests/ -q`; + запись в `CHANGELOG.md`. + +### Вне объёма +- Изменение `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, схемы БД — **не трогаются** (баг + чисто в слое рендера уведомлений). +- Изменение режима трекера (`bump`/`edit`), логики леджера сирот (ORCH-087), статусной модели + ORCH-066, транспортных примитивов (`send_telegram`/`edit_telegram`/`delete_telegram`) — + кроме точечной HTML-безопасности самого текста. +- Редизайн раскладки/состава карточки, новые метрики, перевод строк. +- Изменение машинных вердиктов / frontmatter-контракта. + +## 3. Заинтересованные стороны + +- **Заказчик / репортёр:** Слава (оператор) — обнаружил баг 09.06 (карточка ORCH-093 застряла, + «по 91 уже нету»). +- **Затронуты:** все наблюдатели Telegram-трекера по **всем** проектам (self-hosting: общий + прод-инстанс обслуживает и enduro-trails — карточки их задач так же уязвимы при стадии < 1 мин). +- **Принимает результат:** reviewer/tester конвейера ORCH; финальная приёмка — оператор + (карточки снова обновляются в реальном времени). + +## 4. Бизнес-требования (BR) + +- **BR-1** — Карточка трекера, в тексте которой есть стадия длительностью < 1 мин, должна + успешно редактироваться (`editMessageText` → `200`, не `400 can't parse entities`). Источник + отказа — литерал `<1м` от `_fmt_minutes` — устранён. (⇒ G1, G2) +- **BR-2** — **Все** динамические значения, вставляемые в текст карточки с `parse_mode=HTML` + (длительности, метрики токенов/стоимости, имя модели/эффорта, имена/лейблы стадий, + статус-лейбл, заголовок задачи), HTML-безопасны: символы `< > &` в **данных** не + интерпретируются Telegram как разметка. (⇒ G1) +- **BR-3** — Длительность «меньше минуты» рендерится так, чтобы не выглядеть открывающим + HTML-тегом: экранированный `<1м` **ИЛИ** переформулировка (`~0м` / `< 1 мин`) с + экранированием. Видимое оператору значение остаётся осмысленным («меньше минуты»). (⇒ G2) +- **BR-4** — **Регресс намеренной разметки:** кликабельный номер задачи (``, + `plane_issue_link`) и любое форматирование-обёртка (`` и т.п.) продолжают рендериться и + оставаться кликабельными/валидными — экранируются только подставляемые данные, не разметка. (⇒ G3) +- **BR-5** — Уже застрявшая карточка (класс ORCH-093) после деплоя фикса **возобновляет + обновления**: либо успешный `editMessageText` на следующем переходе стадии, либо + переотправка свежей карточки. Конкретный механизм восстановления (текст снова валиден → + edit проходит, ИЛИ классификация `can't parse entities` как пересоздаваемой) — решение + архитектора; бизнес-требование — карточка перестаёт быть «замёрзшей сиротой». (⇒ G... / AC-4) + +## 5. Нефункциональные требования (NFR) + +- **NFR-1 (never-raise):** `render_task_tracker` и весь путь уведомлений сохраняют контракт + «никогда не роняют конвейер» — любая ошибка рендера/экранирования деградирует к + fallback-строке, не исключение. +- **NFR-2 (нулевая регрессия разметки):** существующие зелёные тесты трекера + (`test_telegram_tracker.py`, `test_tracker_*`, `test_notifications_orphans.py`, + `test_notify_issue_links.py`) остаются зелёными; кликабельность номера и формат строк не + деградируют визуально (кроме намеренной смены вида «<1м»). +- **NFR-3 (self-hosting):** фикс — изменение **только** слоя рендера уведомлений; прод-контейнер + `orchestrator` не перезапускается в рамках стадий разработки; обязательна страховка + `deploy-staging` перед прод-деплоем. Машина стадий/гейты/схема БД не затрагиваются. +- **NFR-4 (совместимость):** изменение обратносовместимо по данным/схеме; не требует миграций; + применяется к новым рендерам сразу после деплоя. + +## 6. Допущения и ограничения + +- Карточка всегда отправляется с `parse_mode=HTML` (`send_telegram:58`, `edit_telegram:175`) — + это инвариант (ссылки/жирный требуют HTML); переход на `parse_mode=None`/MarkdownV2 **не** + рассматривается (сломает намеренную разметку, шире объёма). +- `fmt_tokens`/`fmt_cost` сейчас выдают только цифры/`.`/`k`/`M`/`$` (HTML-безопасно), но + требование BR-2 покрывает их **defence-in-depth** на случай будущих изменений формата. +- Telegram-лимит 48ч: карточки старше 48ч физически неудаляемы/неперезаписываемы — для них + восстановление недостижимо (known-limitation, унаследовано от ORCH-087); BR-5 относится к + карточкам в пределах окна. +- Источник `<1м` — `_fmt_minutes` (единственная функция, эмитящая литерал `<`); прочие данные + лишь потенциально опасны. Точка(и) внесения экранирования — решение архитектора (централизовать + в `_fmt_minutes`/на точке рендера/обёрткой-хелпером). + +## 7. Критерии успеха + +Карточка задачи со стадией < 1 мин успешно редактируется (нет `400 can't parse entities`); +все динамические поля HTML-безопасны; намеренная разметка (ссылка-номер, форматирование) +рендерится и кликабельна; застрявшие карточки возобновляют обновления; `never-raise` сохранён; +`pytest tests/ -q` зелёный; `CHANGELOG.md` обновлён. Детальные PASS/FAIL — `03-acceptance-criteria.md`. + +## 8. Риски + +- **Двойное экранирование** уже экранированных полей (`esc_title`, href/label в + `plane_issue_link`) → `&lt;` в выводе. Митигировать на стадии архитектуры (экранировать + ровно один раз на источник данных). +- **Случайное экранирование разметки-обёртки** (``, ``) → ссылки/жирный перестают + работать (регресс BR-4). Чёткая граница «данные vs обёртка». +- Изменение вида «<1м» меняет визуал карточки — согласовать формулировку с оператором (BR-3 + допускает оба варианта). +- Детали/перечень — `10-tech-risks.md` (заполняет архитектор). diff --git a/docs/work-items/ORCH-095/02-trz.md b/docs/work-items/ORCH-095/02-trz.md new file mode 100644 index 0000000..eb0a5e7 --- /dev/null +++ b/docs/work-items/ORCH-095/02-trz.md @@ -0,0 +1,132 @@ +--- +work_item: ORCH-095 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 02 — ТЗ (TRZ): ORCH-095 — HTML-безопасность динамических полей render_task_tracker + +Work Item: **ORCH-095** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода. +> Архитектурное обоснование/выбор точки внесения экранирования — задача архитектора (06-adr). + +## 1. Сводка изменения + +Текст live-карточки (`render_task_tracker`) собирается с `parse_mode=HTML` из намеренной +разметки-обёртки (`` номер задачи, форматирование) и подставляемых **данных**. Сейчас +экранирован только заголовок (`esc_title`) и href/label внутри `plane_issue_link`; остальные +данные вставляются сырыми. Литерал `<1м` (длительность < 1 мин), возвращаемый `_fmt_minutes`, +Telegram парсит как открывающий тег → `editMessageText` падает `400 can't parse entities` → +`edit_telegram → EDIT_FAILED` → `update_task_tracker` делает ранний `return` → карточка +застывает. + +Требуется: (а) сделать формат «< 1 мин» HTML-безопасным; (б) гарантировать HTML-безопасность +**всех** данных, попадающих в текст карточки, **не** экранируя намеренную разметку-обёртку; +(в) обеспечить возобновление обновлений ранее застрявших карточек. Изменение локализовано в +слое уведомлений; машина стадий/гейты/схема БД не затрагиваются. + +## 2. Задействованные модули / пути + +| Путь | Действие | +|------|----------| +| `src/notifications.py` | **изменить** — `_fmt_minutes` (~280) и/или точки рендера в `render_task_tracker` (~355): HTML-безопасность данных | +| `src/notifications.py::render_task_tracker` | **изменить** — экранировать данные: длительности (`dur`), `status_label`, `model`/`effort`, метрики (defence-in-depth); НЕ трогать `num_html`, `_done_link`-разметку | +| `src/notifications.py::_card_status_label` (~1173) | **проверить/экранировать на потребителе** — статус-лейбл вставляется в `status_line` сырым | +| `src/notifications.py::edit_telegram` (~157) | **возможно изменить** (на усмотрение архитектора) — классификация `can't parse entities` для восстановления застрявших карточек (BR-5/AC-4) | +| `src/notifications.py::update_task_tracker` (~650) | **возможно затронуть** — ветка `EDIT_FAILED` vs пересоздание при перманентном parse-фейле (BR-5/AC-4) | +| `tests/test_telegram_tracker.py` (или новый `tests/test_tracker_html_escape.py`) | **создать/дополнить** — юнит HTML-безопасности всех динамических полей | +| `CHANGELOG.md` | **изменить** — запись о фиксе | + +> Примечание: `fmt_tokens`/`fmt_cost`/`short_model_name` живут в `src/usage.py`; их выход +> сейчас HTML-безопасен (цифры/`.`/`k`/`M`/`$`/имя модели). Менять `src/usage.py` **не +> требуется** — defence-in-depth экранирование делается на потребителе в `notifications.py`. + +## 3. Функциональные требования + +### FR-1 — HTML-безопасный формат «меньше минуты» (⇒ BR-1, BR-3) +Длительность стадии < 60 с не должна порождать подстроку, которую Telegram трактует как +открывающий тег. Текущий `_fmt_minutes(seconds)` при `0 < seconds < 60` возвращает литерал +`"<1м"` (`notifications.py:288-289`). Поведение должно стать одним из (выбор — архитектор): +- экранированный вывод `<1м` (видится оператору как `<1м`), **либо** +- переформулировка `~0м` / `< 1 мин` с последующим экранированием. +Инвариант: для **любого** входа `_fmt_minutes` (включая `0м`, `Nм`, `~Nм` от +`_capped_review_str`) результат, попав в `parse_mode=HTML`, не ломает парсер. `_fmt_minutes` +сохраняет never-raise (нечисловой/None вход → `0м`). + +### FR-2 — HTML-безопасность всех данных карточки (⇒ BR-2) +Каждое **подставляемое значение-данные**, попадающее в текст `render_task_tracker`, +экранируется `html.escape(...)` ровно один раз перед вставкой в HTML-текст. Перечень полей-данных: + +| Поле | Источник | Текущий статус | +|------|----------|----------------| +| Заголовок задачи | `title` → `esc_title` | уже экранирован ✓ (не дублировать) | +| Длительности стадий / BRD / done | `_fmt_minutes`, `_capped_review_str` | **дыра** (FR-1) | +| Статус-лейбл карточки | `_card_status_label` → `status_label` | **дыра** — экранировать | +| Имя модели | `short_model_name(last["model"])` | экранировать (defence-in-depth) | +| Эффорт | `_run_effort(last)` | экранировать (defence-in-depth) | +| Токены / стоимость | `fmt_tokens`/`fmt_cost` | HTML-безопасны; экранировать defence-in-depth | +| Метка «попытка N» / лейблы стадий | статические константы `_TRACKER_STAGES`/`_BRD_LABEL` | статичны; не требуют, но безопасно | + +Инвариант FR-2: после рендера **ни один** символ `< > &`, пришедший из данных, не остаётся +неэкранированным в выходном тексте. + +### FR-3 — Сохранность намеренной разметки-обёртки (⇒ BR-4) +Намеренные HTML-фрагменты **не** экранируются: +- `num_html` = `plane_issue_link(...)` — кликабельный `` номер задачи (внутри уже + экранированы href через `html.escape(url, quote=True)` и label); +- `link_for(...)` в строке «⏳ ждёт …» — намеренные ссылки; +- `_done_link(...)` — строка `🔗 PR #n · 📦 Внедрено`. +После фикса эти фрагменты рендерятся как валидный HTML и остаются кликабельными. Запрещено +двойное экранирование уже экранированных полей (`esc_title`, внутренности `plane_issue_link`). + +### FR-4 — Возобновление обновлений застрявших карточек (⇒ BR-5) +После деплоя фикса карточка, ранее застрявшая на `400 can't parse entities`, должна +возобновить обновления. Достаточное условие по умолчанию: текст следующего рендера больше не +содержит небезопасной подстроки → `editMessageText` проходит (`200`) на ближайшем переходе +стадии. Опционально (решение архитектора): классифицировать перманентный parse-фейл в +`edit_telegram`/`update_task_tracker` как повод **переотправить** свежую карточку вместо +тихого `return` по `EDIT_FAILED` — но **без** регресса защиты от дублей (ORCH-087: транзиентные +фейлы по-прежнему НЕ плодят карточки). Если выбирается переклассификация — она должна отличать +перманентный `can't parse entities` от транзиентного (network/timeout/5xx). + +### FR-5 — never-raise (⇒ NFR-1) +Все изменённые функции сохраняют контракт «никогда не роняют конвейер»: ошибка +экранирования/рендера → деградация к существующему fallback (`f"task-{task_id}"` / +пропуск строки), не исключение наружу. + +## 4. Изменения API + +Нет. HTTP-эндпоинты не добавляются/не меняются. (Внешний вызов — только исходящий +`editMessageText`/`sendMessage` к Telegram Bot API; контракт вызова не меняется, меняется +лишь безопасность `text`.) + +## 5. Изменения схемы БД + +Нет. Таблицы `tasks`/`agent_runs`/`tracker_messages` не затрагиваются; миграций нет. + +## 6. Требования к новым/изменённым QG checks + +Нет. `QG_CHECKS` / `check_*` / `STAGE_TRANSITIONS` / машинные вердикты не затрагиваются. Баг — +в слое рендера уведомлений, вне Quality Gate. + +## 7. Совместимость / регресс + +- **Обратная совместимость:** изменение чисто в формировании строки текста карточки; данные + БД, схема, режимы трекера (`bump`/`edit`), леджер сирот (ORCH-087), статусная модель + (ORCH-066) — без изменений. +- **Область раската:** все проекты на общем прод-инстансе (self-hosting) — фикс применяется к + каждому новому рендеру сразу после деплоя; не требует миграции/бэкфилла. +- **Kill-switch:** не требуется (исправление дефекта корректности, а не новая фича-ветка). Если + архитектор выбирает переклассификацию parse-фейла в `update_task_tracker` (FR-4 опц.) — + оценить целесообразность флага; по умолчанию изменение поведения минимально и безопасно. +- **Обратимость:** изменение откатывается обычным revert PR (только `notifications.py` + + тесты + CHANGELOG); прод-контейнер не требует ручных операций над данными. +- **Артефакты pipeline:** обновляются `12-review.md` (reviewer), `13-test-report.md` (tester), + `06-adr/ADR-001-*.md` (архитектор — выбор точки экранирования и стратегии FR-4), + `CHANGELOG.md`. Машинные вердикты гейтов — без изменений. +- **Self-hosting:** обязательна стадия `deploy-staging` (8501) перед прод-деплоем; прод + `orchestrator` не рестартуется в рамках разработки. diff --git a/docs/work-items/ORCH-095/03-acceptance-criteria.md b/docs/work-items/ORCH-095/03-acceptance-criteria.md new file mode 100644 index 0000000..a4f0049 --- /dev/null +++ b/docs/work-items/ORCH-095/03-acceptance-criteria.md @@ -0,0 +1,97 @@ +--- +work_item: ORCH-095 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 03 — Критерии приёмки (Acceptance Criteria): ORCH-095 — HTML-инъекция «<1м» в render_task_tracker + +Work Item: **ORCH-095** · Repo: **orchestrator** · Стадия: analysis + +Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** +(что считается провалом). Любой машинный/ручной reviewer проверяет их буквально по файлам +репозитория. + +--- + +## AC-1 — Стадия < 1 мин не ломает парсер Telegram + +**Условие:** `render_task_tracker` для задачи, у которой хотя бы одна стадия длилась < 60 с, +выдаёт текст, безопасный для `parse_mode=HTML` (нет неэкранированного `<` в данных длительности). +- **PASS:** В выходном тексте подстрока длительности «меньше минуты» представлена как `<1м` + (или переформулированный безопасный вид `~0м` / `< 1 мин` без сырого `<`); `editMessageText` + с этим текстом не вернул бы `400 can't parse entities: Unsupported start tag "1м"`. Юнит-тест + на `_fmt_minutes(30)` / `render_task_tracker(...)` подтверждает отсутствие сырого `<` от + длительности. +- **FAIL:** Текст содержит сырой `<1м` (или иной литерал `<`+нецифра) из данных длительности; + тест на парсинг/наличие сырого `<` падает. + +--- + +## AC-2 — Все динамические поля карточки HTML-безопасны (юнит) + +**Условие:** Существует юнит-тест, проверяющий, что каждое подставляемое **данные-поле** +`render_task_tracker` экранировано: длительность, токены, стоимость (`$`), заголовок с +спецсимволами `< > &`, статус-лейбл, имя модели/эффорт. +- **PASS:** Тест рендерит карточку с заголовком, содержащим `<`, `>`, `&` (напр. + `"A x & <1"`), и стадией < 1 мин; ассертит, что эти спецсимволы из ДАННЫХ + присутствуют в выводе только в экранированном виде (`<`/`>`/`&`) и НЕ как + сырые теги; одновременно нет двойного экранирования (`&lt;`). +- **FAIL:** Тест отсутствует, либо любое из перечисленных данных-полей попадает в текст без + экранирования, либо обнаруживается двойное экранирование. + +--- + +## AC-3 — Регресс намеренной разметки (ссылка-номер, форматирование) + +**Условие:** После фикса намеренная HTML-разметка карточки продолжает рендериться валидной и +кликабельной. +- **PASS:** Кликабельный номер задачи (`ORCH-095` от `plane_issue_link`) + присутствует в выводе как валидный незаэкранированный ``-тег; строки `🔗 PR #n`/`📦` + (`_done_link`) и любое форматирование-обёртка рендерятся; существующие тесты + `test_tracker_issue_link.py`/`test_notify_issue_links.py`/`test_telegram_tracker.py` + зелёные. Двойного экранирования href/label нет. +- **FAIL:** Номер задачи перестал быть кликабельным (`` заэкранирован в `<a>`), либо + любой регресс-тест разметки красный. + +--- + +## AC-4 — Застрявшая карточка возобновляет обновления + +**Условие:** Карточка, ранее застрявшая на `400 can't parse entities` (класс ORCH-093), после +фикса снова обновляется. +- **PASS:** На следующем переходе стадии текст рендера больше не содержит небезопасной + подстроки → `editMessageText` проходит (`200`); ИЛИ (если выбрана стратегия FR-4-опц.) + перманентный parse-фейл классифицируется как повод переотправить свежую карточку, и + `update_task_tracker` отправляет новую. Поведение покрыто тестом (рендер валиден → edit-путь + не возвращает `EDIT_FAILED` из-за parse-ошибки). +- **FAIL:** После фикса карточка с прежним содержимым по-прежнему даёт `EDIT_FAILED` и не + обновляется/не переотправляется; либо защита от дублей (ORCH-087) сломана — транзиентный + фейл теперь плодит дубликаты карточек. + +--- + +## AC-5 — never-raise, зелёный регресс, CHANGELOG + +**Условие:** Контракт надёжности и гигиена изменения сохранены. +- **PASS:** `render_task_tracker`/`update_task_tracker`/`edit_telegram` не выбрасывают + исключение наружу при любом входе (включая «битый» заголовок/None); `pytest tests/ -q` + полностью зелёный; в `CHANGELOG.md` есть запись о фиксе ORCH-095; `STAGE_TRANSITIONS`/ + `QG_CHECKS`/`check_*`/схема БД не изменены (diff их не трогает). +- **FAIL:** Любой тест в `tests/` красный; обнаружено непойманное исключение в пути рендера; + тронуты машина стадий/гейты/схема БД; нет записи в `CHANGELOG.md`. + +--- + +## Сводная матрица AC ↔ FR/BR + +| AC | Покрывает | +|----|-----------| +| AC-1 | BR-1, BR-3 / FR-1 | +| AC-2 | BR-2 / FR-2 | +| AC-3 | BR-4 / FR-3 | +| AC-4 | BR-5 / FR-4 | +| AC-5 | NFR-1, NFR-2 / FR-5 | diff --git a/docs/work-items/ORCH-095/04-test-plan.yaml b/docs/work-items/ORCH-095/04-test-plan.yaml new file mode 100644 index 0000000..ffb9a6e --- /dev/null +++ b/docs/work-items/ORCH-095/04-test-plan.yaml @@ -0,0 +1,95 @@ +work_item: ORCH-095 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +title: "HTML-безопасность динамических полей render_task_tracker (фикс инъекции «<1м»)" +framework: pytest +scope: > + Покрывается: HTML-безопасность всех подставляемых данных в render_task_tracker + (длительности < 1 мин, токены/стоимость, имя модели/эффорт, статус-лейбл, заголовок со + спецсимволами), сохранность намеренной разметки ( номер задачи, _done_link), + возобновление обновлений застрявшей карточки, never-raise. Вне покрытия: реальная сеть к + Telegram Bot API (мокируется httpx), изменения STAGE_TRANSITIONS/QG_CHECKS/схемы БД (не + трогаются). +notes: > + Тесты — изоляция от сети: httpx.post/get мокируются; БД — временная SQLite-фикстура с + задачей и agent_runs (стадия < 60 с). Полный регресс pytest tests/ -q должен оставаться + зелёным, включая существующие test_telegram_tracker.py / test_tracker_*.py / + test_notifications_orphans.py / test_notify_issue_links.py. Регрессом считается: красный + любой существующий тест трекера, заэкранированная намеренная разметка, двойное + экранирование, непойманное исключение в пути рендера. + +tests: + - id: TC-01 + type: unit + description: "_fmt_minutes для длительности < 60 с (напр. 30) не возвращает сырой '<1м': результат HTML-безопасен (<1м либо переформулированный '~0м'/'< 1 мин' без сырого '<')." + module: tests/test_tracker_html_escape.py + expected: PASS + + - id: TC-02 + type: unit + description: "_fmt_minutes для граничных входов (0, None, нечисловое, ровно 60, большое значение) — never-raise и HTML-безопасный вывод во всех ветках." + module: tests/test_tracker_html_escape.py + expected: PASS + + - id: TC-03 + type: unit + description: "render_task_tracker для задачи со стадией < 1 мин: в выходном тексте нет неэкранированного '<' из данных длительности; подстрока длительности безопасна для parse_mode=HTML." + module: tests/test_tracker_html_escape.py + expected: PASS + + - id: TC-04 + type: unit + description: "render_task_tracker с заголовком, содержащим спецсимволы '<', '>', '&' (напр. 'A x & <1'): спецсимволы данных присутствуют только экранированными (</>/&), не как сырые теги; двойного экранирования (&lt;) нет." + module: tests/test_tracker_html_escape.py + expected: PASS + + - id: TC-05 + type: unit + description: "Статус-лейбл (_card_status_label) и имя модели/эффорт, попадающие в текст карточки, экранированы (defence-in-depth): спецсимволы в них не ломают HTML." + module: tests/test_tracker_html_escape.py + expected: PASS + + - id: TC-06 + type: unit + description: "Метрики токенов/стоимости (fmt_tokens/fmt_cost) в карточке HTML-безопасны: '$' и числовой формат не порождают сырых тегов." + module: tests/test_tracker_html_escape.py + expected: PASS + + - id: TC-07 + type: unit + description: "Регресс намеренной разметки: кликабельный номер задачи (plane_issue_link -> ) присутствует в выводе как валидный незаэкранированный -тег; href/label не задвоены экранированием." + module: tests/test_tracker_html_escape.py + expected: PASS + + - id: TC-08 + type: unit + description: "Регресс _done_link: для завершённой задачи строка '🔗 PR #n · 📦 Внедрено' рендерится валидной (ссылочная разметка не экранирована)." + module: tests/test_tracker_html_escape.py + expected: PASS + + - id: TC-09 + type: integration + description: "update_task_tracker (edit-режим) с замоканным editMessageText: текст карточки со стадией < 1 мин принимается (мок ассертит отсутствие 'can't parse entities'-триггера, т.е. нет сырого '<1м' в payload text)." + module: tests/test_tracker_html_escape.py + expected: PASS + + - id: TC-10 + type: integration + description: "Возобновление застрявшей карточки (AC-4): после фикса валидный рендер проходит edit-путь без EDIT_FAILED из-за parse-ошибки; защита от дублей сохранена — транзиентный (network) фейл по-прежнему НЕ плодит новую карточку." + module: tests/test_tracker_html_escape.py + expected: PASS + + - id: TC-11 + type: unit + description: "never-raise: render_task_tracker на 'битых' входах (отсутствует задача, None-заголовок, нечисловые длительности) возвращает fallback-строку, не выбрасывает исключение." + module: tests/test_tracker_html_escape.py + expected: PASS + + - id: TC-12 + type: integration + description: "Полный регресс существующих тестов трекера (test_telegram_tracker.py, test_tracker_issue_link.py, test_tracker_status_line.py, test_notifications_orphans.py, test_notify_issue_links.py) остаётся зелёным после фикса." + module: tests/test_telegram_tracker.py + expected: PASS