Вики: безопасность и чистка (M-4, M-5, M-6, M-8, M-9, M-10, M-14, M-15 хаб)

- root_set кодирует метку; parse_root вынесен в wiki/roots.py; PATCH wiki_root "" -> 422
- суррогаты в пачке страниц заменяются; dir_name экранирует ведущую точку; error у вложений
- beautifulsoup4 и markdownify<2 объявлены; убраны неиспользуемые импорты и дубль _has_table

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 04:01:54 +04:00
parent 82bbf0f5cd
commit c8af45ced8
23 changed files with 145 additions and 49 deletions

View File

@ -4,7 +4,8 @@ version = "0.1.0"
description = "Мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP" description = "Мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"markdownify>=1.2.3", "beautifulsoup4>=4.12",
"markdownify>=1.2.3,<2",
"mcp>=1.2,<2", "mcp>=1.2,<2",
] ]
@ -31,3 +32,7 @@ filterwarnings = ["error::ResourceWarning", "error::pytest.PytestUnraisableExcep
# Python от uv (python-build-standalone): SQLite в нём своя. В SQLite 3.51.0–3.51.1 взаимная блокировка в unix-VFS # Python от uv (python-build-standalone): SQLite в нём своя. В SQLite 3.51.0–3.51.1 взаимная блокировка в unix-VFS
# вешает хаб под параллельными запросами (O-14, #29); у miniconda — 3.51.1 # вешает хаб под параллельными запросами (O-14, #29); у miniconda — 3.51.1
python-preference = "only-managed" python-preference = "only-managed"
[tool.ruff.lint.per-file-ignores]
# фикстура api импортируется из test_wiki_sync (идиома pytest), а не определяется заново
"tests/test_wiki_api.py" = ["F811"]

View File

@ -1 +1 @@
from .core.cli import main from .core.cli import main # noqa: F401 точка входа jira-hub

View File

@ -403,7 +403,7 @@ def _module_fields(body, skip, *, editable_only: bool) -> dict[str, tuple[object
if editable_only and not fld.editable: if editable_only and not fld.editable:
raise ApiError(422, "bad_project", f"{key} после создания не меняется") raise ApiError(422, "bad_project", f"{key} после создания не меняется")
try: try:
value = fld.validate(value) value = (fld.validate_edit if editable_only and fld.validate_edit else fld.validate)(value)
except ValueError as e: except ValueError as e:
raise ApiError(422, "bad_project", f"{key}: {e}") raise ApiError(422, "bad_project", f"{key}: {e}")
out.setdefault(mod.name, (mod, {}))[1][key] = value out.setdefault(mod.name, (mod, {}))[1][key] = value

View File

@ -188,6 +188,11 @@ def migrate(conn: sqlite3.Connection, path=None) -> None:
conn.execute("INSERT OR IGNORE INTO agent_status(agent) VALUES (?)", (m.name,)) conn.execute("INSERT OR IGNORE INTO agent_status(agent) VALUES (?)", (m.name,))
def has_table(conn: sqlite3.Connection, name: str) -> bool:
"""Есть ли в базе таблица (в том числе виртуальная) с таким именем."""
return conn.execute("SELECT 1 FROM sqlite_master WHERE name = ?", (name,)).fetchone() is not None
_savepoints = itertools.count(1) _savepoints = itertools.count(1)

View File

@ -42,6 +42,7 @@ class ProjectField:
name: str # ключ в теле POST/PATCH и в GET /projects name: str # ключ в теле POST/PATCH и в GET /projects
validate: Callable[[Any], Any] # ValueError(текст) → 422 с полем; возвращает нормализованное значение validate: Callable[[Any], Any] # ValueError(текст) → 422 с полем; возвращает нормализованное значение
editable: bool = False # можно ли менять после создания (PATCH) editable: bool = False # можно ли менять после создания (PATCH)
validate_edit: Callable[[Any], Any] | None = None # проверка при PATCH вместо validate (строже, чем при создании)
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@ -120,7 +120,3 @@ def for_agent(conn, module: str) -> dict:
def tokens(conn) -> dict: def tokens(conn) -> dict:
"""{"client": токен, "agents": {модуль: токен}}.""" """{"client": токен, "agents": {модуль: токен}}."""
return get(conn, "tokens") return get(conn, "tokens")
def agent_token(conn, module: str) -> str:
return tokens(conn)["agents"][module]

View File

@ -8,7 +8,7 @@ from urllib.parse import quote
from ...core import db, files, queue, settings from ...core import db, files, queue, settings
from ...core.errors import CommandError, HttpMapped from ...core.errors import CommandError, HttpMapped
from ...core.http import ApiError, Raw, int_group, int_param, mapped, not_found, project_param from ...core.http import ApiError, Raw, int_param, mapped, not_found, project_param
from . import attachments, fetch, history, mirror, refresh from . import attachments, fetch, history, mirror, refresh
from . import projects as jira_projects from . import projects as jira_projects

View File

@ -72,8 +72,9 @@ def h_page(hub, conn, m, q, body):
if text.startswith("---\n"): # front matter копии клиенту не нужен if text.startswith("---\n"): # front matter копии клиенту не нужен
end = text.find("\n---\n", 4) end = text.find("\n---\n", 4)
text = text[end + 5:].lstrip("\n") if end != -1 else text text = text[end + 5:].lstrip("\n") if end != -1 else text
atts = [{"id": a["id"], "name": a["name"], "state": a["state"], "size": a["size"]} for a in conn.execute( atts = [{"id": a["id"], "name": a["name"], "state": a["state"], "size": a["size"],
"SELECT id, name, state, size FROM wiki_attachment WHERE label = ? AND page_code = ? ORDER BY id", (label, code))] "error": a["error"]} for a in conn.execute(
"SELECT id, name, state, size, error FROM wiki_attachment WHERE label = ? AND page_code = ? ORDER BY id", (label, code))]
return 200, {"code": code, "title": r["title"], "path": r["path"], "url": r["url"], "synced_at": r["synced_at"], return 200, {"code": code, "title": r["title"], "path": r["path"], "url": r["url"], "synced_at": r["synced_at"],
"markdown": text, "attachments": atts} "markdown": text, "attachments": atts}

View File

@ -1,6 +1,5 @@
"""Вложения вики по запросу: состояние, постановка wiki_fetch и хуки его результата (spec wiki «Вложения вики по запросу»). """Вложения вики по запросу: состояние, постановка wiki_fetch и хуки его результата (spec wiki «Вложения вики по запросу»).
Адрес вложения хаб берёт только из своей таблицы wiki_attachment — из запросов клиента он не приходит.""" Адрес вложения хаб берёт только из своей таблицы wiki_attachment — из запросов клиента он не приходит."""
import json
from urllib.parse import quote from urllib.parse import quote
from ...core import db, files, queue from ...core import db, files, queue

View File

@ -9,7 +9,7 @@ from mcp.server.fastmcp import FastMCP
from ...core import mcp as core from ...core import mcp as core
from ...core.client import HubError from ...core.client import HubError
from . import projects as wiki_projects from . import roots as wiki_roots
POLL_SECONDS = 2 POLL_SECONDS = 2
ATTACHMENT_WAIT = 60 ATTACHMENT_WAIT = 60
@ -228,12 +228,12 @@ def root_set(root: str) -> str:
"""Задать корень вики проекта: код DOC-… или ссылка на документ Eva. Хаб ставит синхронизацию нового корня; прежняя """Задать корень вики проекта: код DOC-… или ссылка на документ Eva. Хаб ставит синхронизацию нового корня; прежняя
копия остаётся до её успеха.""" копия остаётся до её успеха."""
try: try:
code = wiki_projects.parse_root(root) code = wiki_roots.parse_root(root)
except ValueError as e: except ValueError as e:
return f"корень не задан: {e}; пример: root_set DOC-123" return f"корень не задан: {e}; пример: root_set DOC-123"
if code is None: if code is None:
return "корень не задан: укажите код DOC-… или ссылку на документ Eva" return "корень не задан: укажите код DOC-… или ссылку на документ Eva"
out = core.client().patch(f"/projects/{core.PROJECT}", {"wiki_root": code, "author": core.AUTHOR}) out = core.client().patch(f"/projects/{quote(core.PROJECT, safe='')}", {"wiki_root": code, "author": core.AUTHOR})
ids = out.get("batch_ids") or [] ids = out.get("batch_ids") or []
return (f"корень вики проекта {core.PROJECT}: {code}; синхронизация поставлена: пакет #{ids[0]}" if ids return (f"корень вики проекта {core.PROJECT}: {code}; синхронизация поставлена: пакет #{ids[0]}" if ids
else f"корень вики проекта {core.PROJECT}: {code}; пакета синхронизации не поставлено (корень не изменился)") else f"корень вики проекта {core.PROJECT}: {code}; пакета синхронизации не поставлено (корень не изменился)")
@ -244,7 +244,7 @@ def project_create(label: str, name: str, wiki_root: str = "") -> str:
"""Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит """Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит
синхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером jira-hub-wiki.""" синхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером jira-hub-wiki."""
try: try:
code = wiki_projects.parse_root(wiki_root) code = wiki_roots.parse_root(wiki_root)
except ValueError as e: except ValueError as e:
return f"проект не заведён: {e}" return f"проект не заведён: {e}"
return core.create_project(label, name, {} if code is None else {"wiki_root": code}, "wiki") return core.create_project(label, name, {} if code is None else {"wiki_root": code}, "wiki")

View File

@ -11,10 +11,10 @@ def root(conn) -> Path:
def dir_name(label: str) -> str: def dir_name(label: str) -> str:
"""Имя каталога проекта: метка, у которой «/», «\\» и «%» заменены, а «.» и «..» закрыты, чтобы метка не выводила """Имя каталога проекта: метка, у которой «/», «\\» и «%» заменены, а ведущая «.» заменена на %2E, чтобы метка не выводила
за каталог вики.""" за каталог вики."""
name = label.replace("%", "%25").replace("/", "%2F").replace("\\", "%5C") name = label.replace("%", "%25").replace("/", "%2F").replace("\\", "%5C")
return {".": "%2E", "..": "%2E%2E"}.get(name, name) return "%2E" + name[1:] if name.startswith(".") else name
def project_dir(conn, label: str) -> Path: def project_dir(conn, label: str) -> Path:

View File

@ -1,29 +1,16 @@
"""Корень вики проекта: поле wiki_root, значения для GET /projects, хуки создания и правки проекта.""" """Корень вики проекта: поле wiki_root, значения для GET /projects, хуки создания и правки проекта."""
import glob import glob
import logging import logging
import re
import shutil import shutil
from ...core import db, queue from ...core import db, queue
from ...core.modules import ProjectField from ...core.modules import ProjectField
from . import commands, paths from . import commands, paths
from .roots import parse_root, parse_root_edit # noqa: F401 parse_root — прежнее имя для импортёров
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_CODE = re.compile(r"(?<![A-Za-z0-9])DOC-(\d+)", re.IGNORECASE) FIELDS = (ProjectField("wiki_root", parse_root, editable=True, validate_edit=parse_root_edit),)
def parse_root(value) -> str | None:
"""Код корня: None/"" — нет корня; строка или ссылка с DOC-<цифры> — код в верхнем регистре; иначе ValueError."""
if value is None or value == "":
return None
m = _CODE.search(value) if isinstance(value, str) else None
if m is None:
raise ValueError("ожидается код DOC-… или ссылка на документ Eva")
return f"DOC-{m.group(1)}"
FIELDS = (ProjectField("wiki_root", parse_root, editable=True),)
def view(conn, labels: list[str]) -> dict[str, dict]: def view(conn, labels: list[str]) -> dict[str, dict]:
@ -60,13 +47,9 @@ def _cancel_queued(conn, label: str) -> None:
queue.cancel_batch(conn, batch_id) queue.cancel_batch(conn, batch_id)
def _has_table(conn, name: str) -> bool:
return conn.execute("SELECT 1 FROM sqlite_master WHERE name = ?", (name,)).fetchone() is not None
def _drop_copy(conn, label: str) -> None: def _drop_copy(conn, label: str) -> None:
"""Сброс корня: строки проекта, индекс поиска (если он уже создан) и каталог копии.""" """Сброс корня: строки проекта, индекс поиска (если он уже создан) и каталог копии."""
if _has_table(conn, "wiki_page_fts"): if db.has_table(conn, "wiki_page_fts"):
conn.execute("DELETE FROM wiki_page_fts WHERE label = ?", (label,)) conn.execute("DELETE FROM wiki_page_fts WHERE label = ?", (label,))
conn.execute("DELETE FROM wiki_page WHERE label = ?", (label,)) conn.execute("DELETE FROM wiki_page WHERE label = ?", (label,))
conn.execute("DELETE FROM wiki_attachment WHERE label = ?", (label,)) conn.execute("DELETE FROM wiki_attachment WHERE label = ?", (label,))

View File

@ -0,0 +1,21 @@
"""Разбор корня вики: чистые функции без обращения к базе и очереди (их безопасно импортирует и MCP)."""
import re
_CODE = re.compile(r"(?<![A-Za-z0-9])DOC-(\d+)", re.IGNORECASE)
def parse_root(value) -> str | None:
"""Код корня: None/"" — нет корня; строка или ссылка с DOC-<цифры> — код в верхнем регистре; иначе ValueError."""
if value is None or value == "":
return None
m = _CODE.search(value) if isinstance(value, str) else None
if m is None:
raise ValueError("ожидается код DOC-… или ссылка на документ Eva")
return f"DOC-{m.group(1)}"
def parse_root_edit(value) -> str | None:
"""Правка корня (PATCH): сбрасывает только null; пустая строка — ошибка, чтобы случайное "" не удаляло копию (M-6)."""
if value == "":
raise ValueError("пустая строка не сбрасывает корень: передайте null")
return parse_root(value)

View File

@ -3,6 +3,7 @@ import logging
import re import re
import sqlite3 import sqlite3
from ...core import db
from . import paths from . import paths
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -11,14 +12,10 @@ _WORD = re.compile(r"\w+")
_warned = False _warned = False
def _has_table(conn) -> bool:
return conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'wiki_page_fts'").fetchone() is not None
def ensure(conn) -> bool: def ensure(conn) -> bool:
"""Создать индекс, если его нет. False — FTS5 недоступен (одно предупреждение в лог на процесс).""" """Создать индекс, если его нет. False — FTS5 недоступен (одно предупреждение в лог на процесс)."""
global _warned global _warned
if _has_table(conn): if db.has_table(conn, "wiki_page_fts"):
return True return True
try: try:
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS wiki_page_fts USING fts5(label UNINDEXED, code UNINDEXED, " conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS wiki_page_fts USING fts5(label UNINDEXED, code UNINDEXED, "
@ -52,7 +49,7 @@ def query(conn, label: str, q: str, limit: int) -> list[dict]:
if match is None: if match is None:
return [] return []
pages = {r["code"]: r for r in conn.execute("SELECT code, title, path FROM wiki_page WHERE label = ?", (label,))} pages = {r["code"]: r for r in conn.execute("SELECT code, title, path FROM wiki_page WHERE label = ?", (label,))}
if _has_table(conn): if db.has_table(conn, "wiki_page_fts"):
try: try:
rows = conn.execute("SELECT code, title, snippet(wiki_page_fts, 3, '', '', '…', 16) AS snippet " rows = conn.execute("SELECT code, title, snippet(wiki_page_fts, 3, '', '', '…', 16) AS snippet "
"FROM wiki_page_fts WHERE wiki_page_fts MATCH ? AND label = ? ORDER BY rank LIMIT ?", "FROM wiki_page_fts WHERE wiki_page_fts MATCH ? AND label = ? ORDER BY rank LIMIT ?",

View File

@ -23,10 +23,23 @@ def _text(page, key, code, required=True, nullable=False):
return v if v.strip() else None # пустое необязательное поле (parent_code "") — как отсутствующее return v if v.strip() else None # пустое необязательное поле (parent_code "") — как отсутствующее
def _clean(v):
"""Строки с одиночным суррогатом (JSON \\ud83d без пары) не кодируются в UTF-8 и роняли запись стадии: заменяем
их на U+FFFD по всей странице (M-8 финального ревью)."""
if isinstance(v, str):
return v.encode("utf-8", "replace").decode("utf-8")
if isinstance(v, list):
return [_clean(x) for x in v]
if isinstance(v, dict):
return {k: _clean(x) for k, x in v.items()}
return v
def _check(i, page) -> dict: def _check(i, page) -> dict:
"""Форма одной страницы → поля для стадии (без html); ApiError 422 `<код>: <поле>`.""" """Форма одной страницы → поля для стадии (без html); ApiError 422 `<код>: <поле>`."""
if not isinstance(page, dict): if not isinstance(page, dict):
raise ApiError(422, "bad_pages", f"страница {i}: не объект") raise ApiError(422, "bad_pages", f"страница {i}: не объект")
page = _clean(page)
code = page.get("code") code = page.get("code")
if not isinstance(code, str) or not code.strip() or len(code) > LIMITS["code"]: if not isinstance(code, str) or not code.strip() or len(code) > LIMITS["code"]:
raise ApiError(422, "bad_pages", f"страница {i}: code") raise ApiError(422, "bad_pages", f"страница {i}: code")

View File

@ -11,7 +11,7 @@ from datetime import timedelta
import pytest import pytest
from jira_hub.core import api, db, files, projects, queue, release, settings from jira_hub.core import api, db, files, queue, release, settings
from jira_hub.core.client import HubClient, HubError from jira_hub.core.client import HubClient, HubError
from jira_hub.modules.jira import api as jira_api from jira_hub.modules.jira import api as jira_api
from jira_hub.modules.jira.commands import CommandError from jira_hub.modules.jira.commands import CommandError

View File

@ -145,7 +145,7 @@ def test_batch_wait_timeout(monkeypatch):
def test_batch_wait_clamps_timeout(monkeypatch, asked, minutes): def test_batch_wait_clamps_timeout(monkeypatch, asked, minutes):
fake = FakeClient(["running"]) fake = FakeClient(["running"])
monkeypatch.setattr(core_mcp, "client", lambda: fake) monkeypatch.setattr(core_mcp, "client", lambda: fake)
clock = _fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута _fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута
asyncio.run(jmcp.batch_wait(7, timeout_minutes=asked)) asyncio.run(jmcp.batch_wait(7, timeout_minutes=asked))
# первый вызов часов (1 мин) задаёт старт; дедлайн = старт + minutes; выход, когда now >= дедлайна # первый вызов часов (1 мин) задаёт старт; дедлайн = старт + minutes; выход, когда now >= дедлайна
assert fake.calls == minutes assert fake.calls == minutes

View File

@ -2,7 +2,7 @@ import hashlib
import pytest import pytest
from jira_hub.core import api, db, files, projects, queue, settings from jira_hub.core import api, db, files, queue, settings
from jira_hub.modules.jira import attachments, commands, mirror, refresh from jira_hub.modules.jira import attachments, commands, mirror, refresh
from jira_hub.modules.jira import fetch as jira_fetch from jira_hub.modules.jira import fetch as jira_fetch
from jira_hub.modules.jira import projects as jira_projects from jira_hub.modules.jira import projects as jira_projects

View File

@ -62,7 +62,7 @@ def test_status_tree_page(api):
p = api.client.get("/wiki/pages/DOC-3", project="AWPCA") p = api.client.get("/wiki/pages/DOC-3", project="AWPCA")
assert p["title"] == "Статусы" and p["url"].endswith("DOC-3") and p["synced_at"] assert p["title"] == "Статусы" and p["url"].endswith("DOC-3") and p["synced_at"]
assert f"attachment:{aid}" in p["markdown"] and not p["markdown"].startswith("---") assert f"attachment:{aid}" in p["markdown"] and not p["markdown"].startswith("---")
assert p["attachments"] == [{"id": aid, "name": "model.xlsx", "state": "none", "size": None}] assert p["attachments"] == [{"id": aid, "name": "model.xlsx", "state": "none", "size": None, "error": None}]
assert err(api.client.get, "/wiki/pages/DOC-99", project="AWPCA").status == 404 assert err(api.client.get, "/wiki/pages/DOC-99", project="AWPCA").status == 404
@ -216,3 +216,15 @@ def test_search_results(api):
first_sync(api) first_sync(api)
r = api.client.get("/wiki/search", project="AWPCA", q="согласов")["results"] r = api.client.get("/wiki/search", project="AWPCA", q="согласов")["results"]
assert [x["code"] for x in r] == ["DOC-3"] and set(r[0]) == {"code", "title", "path", "snippet"} assert [x["code"] for x in r] == ["DOC-3"] and set(r[0]) == {"code", "title", "path", "snippet"}
def test_page_attachment_failed_error_visible(api):
"""M-15: ответ страницы несёт error у вложения, текст ошибки виден без клика."""
aid = synced(api)
c = api.hub.conn()
try:
c.execute("UPDATE wiki_attachment SET state = 'failed', error = 'Eva: 404' WHERE id = ?", (aid,))
finally:
c.close()
att = api.client.get("/wiki/pages/DOC-3", project="AWPCA")["attachments"][0]
assert att["state"] == "failed" and att["error"] == "Eva: 404"

View File

@ -226,7 +226,7 @@ def test_sync_nowait_and_in_progress(hub):
def test_sync_wait(hub): def test_sync_wait(hub):
done = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "done", "error": None}]} done = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "done", "error": None}]}
fake = hub({("POST", "/wiki/sync"): {"batch_id": 5}, hub({("POST", "/wiki/sync"): {"batch_id": 5},
"/batches/5": [{"id": 5, "status": "running", "commands": []}, done], "/batches/5": [{"id": 5, "status": "running", "commands": []}, done],
"/wiki/status": {"root": "DOC-1", "root_title": "К", "synced_at": "s", "pages": 3, "error": None, "batch": None}}) "/wiki/status": {"root": "DOC-1", "root_title": "К", "synced_at": "s", "pages": 3, "error": None, "batch": None}})
out = asyncio.run(wmcp.sync(wait=True)) out = asyncio.run(wmcp.sync(wait=True))
@ -304,3 +304,12 @@ def test_projects_table_and_create(hub):
created = wmcp.project_create("n", "Н", "DOC-5") created = wmcp.project_create("n", "Н", "DOC-5")
assert "jira-hub-wiki" in created assert "jira-hub-wiki" in created
assert fake.calls[-1][2]["wiki_root"] == "DOC-5" assert fake.calls[-1][2]["wiki_root"] == "DOC-5"
def test_root_set_quotes_label(hub):
"""M-4: метка со спецсимволами кодируется в адресе PATCH."""
label = "A#B?c%d/e"
fake = hub({("PATCH", "/projects/A%23B%3Fc%25d%2Fe"): {"project": {}, "batch_ids": [5]}}, project=label)
fake.routes["/projects"] = {"projects": [{"label": label, "name": "x", "wiki_root": None}]}
assert "#5" in wmcp.root_set("DOC-2")
assert fake.calls[-1][1] == "/projects/A%23B%3Fc%25d%2Fe"

View File

@ -119,3 +119,33 @@ def test_claimed_sync_is_not_cancelled_by_new_root(hub_server):
conn.close() conn.close()
c.patch("/projects/AWPCA", {"wiki_root": "DOC-21"}) c.patch("/projects/AWPCA", {"wiki_root": "DOC-21"})
assert [(s, p["root"]) for _, s, p in _syncs(hub_server["hub"])] == [("claimed", "DOC-12"), ("queued", "DOC-21")] assert [(s, p["root"]) for _, s, p in _syncs(hub_server["hub"])] == [("claimed", "DOC-12"), ("queued", "DOC-21")]
def test_patch_root_empty_string_rejected(hub_server):
"""M-6: PATCH {wiki_root: ""} — 422, копия цела; сбрасывает только null; POST с "" — проект без корня."""
c = hub_server["client"]
c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-1"})
with pytest.raises(HubError) as e:
c.patch("/projects/AWPCA", {"wiki_root": ""})
assert e.value.status == 422 and "wiki_root" in e.value.detail
assert c.get("/projects")["projects"][0]["wiki_root"] == "DOC-1"
out = c.post("/projects", {"label": "S", "name": "S", "wiki_root": ""})
assert out["project"]["wiki_root"] is None
assert c.patch("/projects/AWPCA", {"wiki_root": None})["project"]["wiki_root"] is None
def test_wiki_mcp_imports_no_db_or_queue():
"""M-5: mcp.py и roots.py вики не импортируют core.db и core.queue ни прямо, ни через wiki.projects."""
import ast
from pathlib import Path
base = Path(__file__).parent.parent / "src" / "jira_hub" / "modules" / "wiki"
for name in ("mcp.py", "roots.py"):
tree = ast.parse((base / name).read_text(encoding="utf-8"))
mods = []
for n in ast.walk(tree):
if isinstance(n, ast.ImportFrom):
mods += [f"{'.' * n.level}{n.module or ''}.{a.name}" for a in n.names]
elif isinstance(n, ast.Import):
mods += [a.name for a in n.names]
bad = [m for m in mods if m.endswith((".db", ".queue", ".projects")) or ".core.db" in m or ".core.queue" in m]
assert not bad, (name, bad)

View File

@ -606,3 +606,25 @@ def test_housekeeping_ensures_fts(open_db, tmp_path):
assert not conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'wiki_page_fts'").fetchone() assert not conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'wiki_page_fts'").fetchone()
tick.housekeeping_tick(None, conn) tick.housekeeping_tick(None, conn)
assert conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'wiki_page_fts'").fetchone() assert conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'wiki_page_fts'").fetchone()
def test_lone_surrogate_in_page_does_not_break_sync(api):
"""M-8: одиночный суррогат (JSON-эскейп \\ud83d без пары) в html/title не роняет запись стадии (раньше 500)."""
import urllib.request
bad = [page("DOC-1", "Корень \ud83d", html="<p>текст \ud83d конец</p>")]
cmd = api.claim()
body = json.dumps({"command_id": cmd["id"], "pages": bad}).encode("ascii") # ensure_ascii: эскейп остаётся
req = urllib.request.Request(api.base + "/agent/wiki/pages", data=body, method="POST",
headers={"Authorization": "Bearer " + api.wiki.token, "Content-Type": "application/json"})
with urllib.request.urlopen(req) as r:
assert r.status == 200
api.report(cmd, "done", {"root_title": "К", "codes": ["DOC-1"], "eva_calls": 1})
assert api.rows("SELECT title FROM wiki_page WHERE code = 'DOC-1'")[0]["title"] == "Корень ?" # кодек заменяет суррогат на «?»
def test_dir_name_escapes_leading_dot():
"""M-14: метка с ведущей точкой не даёт каталог вида .X.new.5 / .X.old, который уборка при старте сочла бы своим."""
from jira_hub.modules.wiki import paths
assert paths.dir_name(".X.new.5") == "%2EX.new.5" and paths.dir_name(".") == "%2E" and paths.dir_name("..") == "%2E."
assert not paths.dir_name(".X.old").startswith(".") and paths.dir_name("A.B") == "A.B"

4
uv.lock generated
View File

@ -282,6 +282,7 @@ name = "jira-hub"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "beautifulsoup4" },
{ name = "markdownify" }, { name = "markdownify" },
{ name = "mcp" }, { name = "mcp" },
] ]
@ -293,7 +294,8 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "markdownify", specifier = ">=1.2.3" }, { name = "beautifulsoup4", specifier = ">=4.12" },
{ name = "markdownify", specifier = ">=1.2.3,<2" },
{ name = "mcp", specifier = ">=1.2,<2" }, { name = "mcp", specifier = ">=1.2,<2" },
] ]