Вики: безопасность и чистка (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:
parent
82bbf0f5cd
commit
c8af45ced8
@ -4,7 +4,8 @@ version = "0.1.0"
|
||||
description = "Мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"markdownify>=1.2.3",
|
||||
"beautifulsoup4>=4.12",
|
||||
"markdownify>=1.2.3,<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
|
||||
# вешает хаб под параллельными запросами (O-14, #29); у miniconda — 3.51.1
|
||||
python-preference = "only-managed"
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# фикстура api импортируется из test_wiki_sync (идиома pytest), а не определяется заново
|
||||
"tests/test_wiki_api.py" = ["F811"]
|
||||
|
||||
@ -1 +1 @@
|
||||
from .core.cli import main
|
||||
from .core.cli import main # noqa: F401 точка входа jira-hub
|
||||
|
||||
@ -403,7 +403,7 @@ def _module_fields(body, skip, *, editable_only: bool) -> dict[str, tuple[object
|
||||
if editable_only and not fld.editable:
|
||||
raise ApiError(422, "bad_project", f"{key} после создания не меняется")
|
||||
try:
|
||||
value = fld.validate(value)
|
||||
value = (fld.validate_edit if editable_only and fld.validate_edit else fld.validate)(value)
|
||||
except ValueError as e:
|
||||
raise ApiError(422, "bad_project", f"{key}: {e}")
|
||||
out.setdefault(mod.name, (mod, {}))[1][key] = value
|
||||
|
||||
@ -188,6 +188,11 @@ def migrate(conn: sqlite3.Connection, path=None) -> None:
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@ -42,6 +42,7 @@ class ProjectField:
|
||||
name: str # ключ в теле POST/PATCH и в GET /projects
|
||||
validate: Callable[[Any], Any] # ValueError(текст) → 422 с полем; возвращает нормализованное значение
|
||||
editable: bool = False # можно ли менять после создания (PATCH)
|
||||
validate_edit: Callable[[Any], Any] | None = None # проверка при PATCH вместо validate (строже, чем при создании)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@ -120,7 +120,3 @@ def for_agent(conn, module: str) -> dict:
|
||||
def tokens(conn) -> dict:
|
||||
"""{"client": токен, "agents": {модуль: токен}}."""
|
||||
return get(conn, "tokens")
|
||||
|
||||
|
||||
def agent_token(conn, module: str) -> str:
|
||||
return tokens(conn)["agents"][module]
|
||||
|
||||
@ -8,7 +8,7 @@ from urllib.parse import quote
|
||||
|
||||
from ...core import db, files, queue, settings
|
||||
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 projects as jira_projects
|
||||
|
||||
|
||||
@ -72,8 +72,9 @@ def h_page(hub, conn, m, q, body):
|
||||
if text.startswith("---\n"): # front matter копии клиенту не нужен
|
||||
end = text.find("\n---\n", 4)
|
||||
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(
|
||||
"SELECT id, name, state, size FROM wiki_attachment WHERE label = ? AND page_code = ? ORDER BY id", (label, code))]
|
||||
atts = [{"id": a["id"], "name": a["name"], "state": a["state"], "size": a["size"],
|
||||
"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"],
|
||||
"markdown": text, "attachments": atts}
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
"""Вложения вики по запросу: состояние, постановка wiki_fetch и хуки его результата (spec wiki «Вложения вики по запросу»).
|
||||
Адрес вложения хаб берёт только из своей таблицы wiki_attachment — из запросов клиента он не приходит."""
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
|
||||
from ...core import db, files, queue
|
||||
|
||||
@ -9,7 +9,7 @@ from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from ...core import mcp as core
|
||||
from ...core.client import HubError
|
||||
from . import projects as wiki_projects
|
||||
from . import roots as wiki_roots
|
||||
|
||||
POLL_SECONDS = 2
|
||||
ATTACHMENT_WAIT = 60
|
||||
@ -228,12 +228,12 @@ def root_set(root: str) -> str:
|
||||
"""Задать корень вики проекта: код DOC-… или ссылка на документ Eva. Хаб ставит синхронизацию нового корня; прежняя
|
||||
копия остаётся до её успеха."""
|
||||
try:
|
||||
code = wiki_projects.parse_root(root)
|
||||
code = wiki_roots.parse_root(root)
|
||||
except ValueError as e:
|
||||
return f"корень не задан: {e}; пример: root_set DOC-123"
|
||||
if code is None:
|
||||
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 []
|
||||
return (f"корень вики проекта {core.PROJECT}: {code}; синхронизация поставлена: пакет #{ids[0]}" if ids
|
||||
else f"корень вики проекта {core.PROJECT}: {code}; пакета синхронизации не поставлено (корень не изменился)")
|
||||
@ -244,7 +244,7 @@ def project_create(label: str, name: str, wiki_root: str = "") -> str:
|
||||
"""Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит
|
||||
синхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером jira-hub-wiki."""
|
||||
try:
|
||||
code = wiki_projects.parse_root(wiki_root)
|
||||
code = wiki_roots.parse_root(wiki_root)
|
||||
except ValueError as e:
|
||||
return f"проект не заведён: {e}"
|
||||
return core.create_project(label, name, {} if code is None else {"wiki_root": code}, "wiki")
|
||||
|
||||
@ -11,10 +11,10 @@ def root(conn) -> Path:
|
||||
|
||||
|
||||
def dir_name(label: str) -> str:
|
||||
"""Имя каталога проекта: метка, у которой «/», «\\» и «%» заменены, а «.» и «..» закрыты, чтобы метка не выводила
|
||||
"""Имя каталога проекта: метка, у которой «/», «\\» и «%» заменены, а ведущая «.» заменена на %2E, чтобы метка не выводила
|
||||
за каталог вики."""
|
||||
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:
|
||||
|
||||
@ -1,29 +1,16 @@
|
||||
"""Корень вики проекта: поле wiki_root, значения для GET /projects, хуки создания и правки проекта."""
|
||||
import glob
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from ...core import db, queue
|
||||
from ...core.modules import ProjectField
|
||||
from . import commands, paths
|
||||
from .roots import parse_root, parse_root_edit # noqa: F401 parse_root — прежнее имя для импортёров
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_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)}"
|
||||
|
||||
|
||||
FIELDS = (ProjectField("wiki_root", parse_root, editable=True),)
|
||||
FIELDS = (ProjectField("wiki_root", parse_root, editable=True, validate_edit=parse_root_edit),)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
"""Сброс корня: строки проекта, индекс поиска (если он уже создан) и каталог копии."""
|
||||
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 WHERE label = ?", (label,))
|
||||
conn.execute("DELETE FROM wiki_attachment WHERE label = ?", (label,))
|
||||
|
||||
21
src/jira_hub/modules/wiki/roots.py
Normal file
21
src/jira_hub/modules/wiki/roots.py
Normal 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)
|
||||
@ -3,6 +3,7 @@ import logging
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from ...core import db
|
||||
from . import paths
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@ -11,14 +12,10 @@ _WORD = re.compile(r"\w+")
|
||||
_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:
|
||||
"""Создать индекс, если его нет. False — FTS5 недоступен (одно предупреждение в лог на процесс)."""
|
||||
global _warned
|
||||
if _has_table(conn):
|
||||
if db.has_table(conn, "wiki_page_fts"):
|
||||
return True
|
||||
try:
|
||||
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:
|
||||
return []
|
||||
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:
|
||||
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 ?",
|
||||
|
||||
@ -23,10 +23,23 @@ def _text(page, key, code, required=True, nullable=False):
|
||||
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:
|
||||
"""Форма одной страницы → поля для стадии (без html); ApiError 422 `<код>: <поле>`."""
|
||||
if not isinstance(page, dict):
|
||||
raise ApiError(422, "bad_pages", f"страница {i}: не объект")
|
||||
page = _clean(page)
|
||||
code = page.get("code")
|
||||
if not isinstance(code, str) or not code.strip() or len(code) > LIMITS["code"]:
|
||||
raise ApiError(422, "bad_pages", f"страница {i}: code")
|
||||
|
||||
@ -11,7 +11,7 @@ from datetime import timedelta
|
||||
|
||||
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.modules.jira import api as jira_api
|
||||
from jira_hub.modules.jira.commands import CommandError
|
||||
|
||||
@ -145,7 +145,7 @@ def test_batch_wait_timeout(monkeypatch):
|
||||
def test_batch_wait_clamps_timeout(monkeypatch, asked, minutes):
|
||||
fake = FakeClient(["running"])
|
||||
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))
|
||||
# первый вызов часов (1 мин) задаёт старт; дедлайн = старт + minutes; выход, когда now >= дедлайна
|
||||
assert fake.calls == minutes
|
||||
|
||||
@ -2,7 +2,7 @@ import hashlib
|
||||
|
||||
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 fetch as jira_fetch
|
||||
from jira_hub.modules.jira import projects as jira_projects
|
||||
|
||||
@ -62,7 +62,7 @@ def test_status_tree_page(api):
|
||||
p = api.client.get("/wiki/pages/DOC-3", project="AWPCA")
|
||||
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 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
|
||||
|
||||
|
||||
@ -216,3 +216,15 @@ def test_search_results(api):
|
||||
first_sync(api)
|
||||
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"}
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@ -226,7 +226,7 @@ def test_sync_nowait_and_in_progress(hub):
|
||||
|
||||
def test_sync_wait(hub):
|
||||
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],
|
||||
"/wiki/status": {"root": "DOC-1", "root_title": "К", "synced_at": "s", "pages": 3, "error": None, "batch": None}})
|
||||
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")
|
||||
assert "jira-hub-wiki" in created
|
||||
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"
|
||||
|
||||
@ -119,3 +119,33 @@ def test_claimed_sync_is_not_cancelled_by_new_root(hub_server):
|
||||
conn.close()
|
||||
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")]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@ -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()
|
||||
tick.housekeeping_tick(None, conn)
|
||||
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
4
uv.lock
generated
@ -282,6 +282,7 @@ name = "jira-hub"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "markdownify" },
|
||||
{ name = "mcp" },
|
||||
]
|
||||
@ -293,7 +294,8 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
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" },
|
||||
]
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user