From bc610a7734e4827044d89149a190c39279b7fb4e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 20:03:28 +0400 Subject: [PATCH] =?UTF-8?q?feat(api):=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=20=D0=B2=D0=B5=D0=B1-=D0=BF=D1=80=D0=B8=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20/app?= =?UTF-8?q?=E2=80=A6=20=D0=B1=D0=B5=D0=B7=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0,=20503=20=D0=B1=D0=B5=D0=B7=20=D1=81=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=BA=D0=B8;=20=D1=82=D0=B5=D1=81=D1=82=20M-7=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Haiku 5.5 --- src/jira_hub/api.py | 36 ++++++++++++--- tests/test_mirror.py | 12 +++++ tests/test_web_app.py | 100 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 tests/test_web_app.py diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index ce5c2b2..87e6a86 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -1,6 +1,6 @@ """HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос). Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md). -Страница доски (GET /, GET /links) — без токена: статический файл без данных.""" +Без токена — статика без данных: страница доски (GET /, GET /links) и веб-приложение (GET /app…).""" import hmac import json import logging @@ -11,7 +11,7 @@ import traceback from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import NamedTuple -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, unquote, urlparse from . import backup, db, mirror, queue, relay, settings from .commands import CommandError @@ -23,13 +23,20 @@ log = logging.getLogger(__name__) MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт PAGE = Path(__file__).parent / "web" / "links.html" # страница доски связей; читается на каждый запрос +WEB_APP = Path(__file__).parent / "web" / "app" # сборка веб-приложения (make web, не в git); читается на каждый запрос +ASSET_TYPES = {".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", + ".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".map": "application/json"} +ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # имена файлов сборки содержат хэш (R-3) +NOT_BUILT = ("Хаб Jira

Веб-приложение не собрано: выполните " + "make web в каталоге jira-hub и перезапустите хаб.

").encode("utf-8") SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит class Raw(NamedTuple): - """Не-JSON ответ обработчика (страница доски).""" + """Не-JSON ответ обработчика (страница доски, веб-приложение). headers=None — Cache-Control: no-cache.""" content_type: str data: bytes + headers: dict | None = None class ApiError(Exception): @@ -39,8 +46,9 @@ class ApiError(Exception): class Hub: - def __init__(self, db_path, backups_dir=None): + def __init__(self, db_path, backups_dir=None, web_dir=None): self.db_path = Path(db_path) + self.web_dir = Path(web_dir) if web_dir else WEB_APP self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() self.claim_wait = 30 secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии @@ -155,6 +163,22 @@ def h_page(hub, conn, m, q, body): return 200, Raw("text/html; charset=utf-8", PAGE.read_bytes()) +def h_app(hub, conn, m, q, body): + """Веб-приложение: файлы assets/ сборки как есть, любой другой путь /app… — index.html (маршруты на клиенте). + Путь файла после разрешения обязан лежать внутри assets/, иначе 404; без сборки — 503 с подсказкой.""" + index = hub.web_dir / "index.html" + if not index.is_file(): + return 503, Raw("text/html; charset=utf-8", NOT_BUILT) + rest = unquote(m.group(1) or "") + if rest.startswith("assets/"): + assets = (hub.web_dir / "assets").resolve() + f = (hub.web_dir / rest).resolve() + if not f.is_relative_to(assets) or not f.is_file(): + raise ApiError(404, "not_found", "/app/" + rest) + return 200, Raw(ASSET_TYPES.get(f.suffix, "application/octet-stream"), f.read_bytes(), ASSET_CACHE) + return 200, Raw("text/html; charset=utf-8", index.read_bytes()) + + def h_note_put(hub, conn, m, q, body): fields = {k: v for k, v in body.items() if k != "updated_by"} try: @@ -263,6 +287,7 @@ def h_settings_put(hub, conn, m, q, body): ROUTES = [ ("GET", r"^/(links)?$", h_page, None), + ("GET", r"^/app(?:/(.*))?$", h_app, None), ("POST", r"^/agent/claim$", h_claim, "agent"), ("GET", r"^/agent/settings$", h_agent_settings, "agent"), ("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"), @@ -355,7 +380,8 @@ class Handler(BaseHTTPRequestHandler): def _send(self, status, payload): if isinstance(payload, Raw): - ctype, data, extra = payload.content_type, payload.data, {"Cache-Control": "no-cache"} + ctype, data = payload.content_type, payload.data + extra = payload.headers if payload.headers is not None else {"Cache-Control": "no-cache"} else: ctype, extra = "application/json; charset=utf-8", {} data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8") diff --git a/tests/test_mirror.py b/tests/test_mirror.py index 836a8d7..e45e428 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -180,3 +180,15 @@ def test_board_last_sync_is_last_successful_full_refresh(): _sync_run(conn, "2026-10-14T13:00:00+00:00", None, "jql", BASE, status="claimed", count=50) assert mirror.board(conn, BASE)["last_sync"] == {"started_at": "2026-10-14T09:59:00+00:00", "finished_at": "2026-10-14T10:00:00+00:00", "issues_count": 198} + + +def test_board_inside_open_transaction_keeps_it_open(): + """M-7 #20: вызов внутри уже открытой транзакции не открывает и не закрывает свою.""" + conn = db.connect(":memory:") + mirror.apply_state(conn, [issue("AURORA-1620")], []) + with db.transaction(conn): + assert conn.in_transaction + snap = mirror.board(conn, BASE) + assert conn.in_transaction + assert not conn.in_transaction + assert [i["key"] for i in snap["issues"]] == ["AURORA-1620"] diff --git a/tests/test_web_app.py b/tests/test_web_app.py new file mode 100644 index 0000000..1a57bbc --- /dev/null +++ b/tests/test_web_app.py @@ -0,0 +1,100 @@ +"""Раздача веб-приложения: GET /app… без токена (specs/hub-api «Раздача веб-приложения»).""" +import http.client +import threading +from urllib.parse import urlparse + +import pytest + +from jira_hub import api + + +def _get(base, path): + """Сырой GET: http.client не нормализует путь (../ и %2e%2e доходят до хаба как есть).""" + u = urlparse(base) + c = http.client.HTTPConnection(u.hostname, u.port, timeout=10) + try: + c.request("GET", path) + r = c.getresponse() + return r.status, r, r.read() + finally: + c.close() + + +def _serve(tmp_path, web_dir): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups", web_dir=web_dir) + srv = api.make_server(h, "127.0.0.1", 0) + th = threading.Thread(target=srv.serve_forever, daemon=True) + th.start() + return srv, th, f"http://127.0.0.1:{srv.server_port}" + + +@pytest.fixture +def app_hub(tmp_path): + web = tmp_path / "app" + (web / "assets").mkdir(parents=True) + (web / "index.html").write_text("
", encoding="utf-8") + (web / "assets" / "index-abc123.js").write_text("console.log('ok')", encoding="utf-8") + (web / "assets" / "index-abc123.css").write_text("body{}", encoding="utf-8") + (web / "assets" / "font.bin").write_bytes(b"\x00\x01") + (tmp_path / "secret.txt").write_text("секрет", encoding="utf-8") + srv, th, base = _serve(tmp_path, web) + yield base + srv.shutdown() + srv.server_close() + th.join(5) + + +def test_asset_served_with_type_and_immutable_cache(app_hub): + status, r, body = _get(app_hub, "/app/assets/index-abc123.js") + assert status == 200 + assert r.getheader("Content-Type") == "text/javascript; charset=utf-8" + assert r.getheader("Cache-Control") == "public, max-age=31536000, immutable" + assert body == b"console.log('ok')" + status, r, _ = _get(app_hub, "/app/assets/index-abc123.css") + assert r.getheader("Content-Type") == "text/css; charset=utf-8" + status, r, _ = _get(app_hub, "/app/assets/font.bin") + assert status == 200 and r.getheader("Content-Type") == "application/octet-stream" + + +@pytest.mark.parametrize("path", ["/app", "/app/", "/app/board", "/app/issues/AURORA-1620", "/app/issues?sprint=x"]) +def test_app_paths_get_index_without_token(app_hub, path): + status, r, body = _get(app_hub, path) + assert status == 200 + assert r.getheader("Content-Type") == "text/html; charset=utf-8" + assert r.getheader("Cache-Control") == "no-cache" + assert body == b"
" + + +@pytest.mark.parametrize("path", ["/app/assets/../../secret.txt", "/app/assets/%2e%2e/%2e%2e/secret.txt", + "/app/assets/..%2F..%2Fsecret.txt", "/app/assets/missing.js", "/app/assets/"]) +def test_asset_outside_or_missing_is_404(app_hub, path): + status, _, body = _get(app_hub, path) + assert status == 404 + assert "секрет".encode("utf-8") not in body + + +def test_not_built_is_503_with_hint(tmp_path): + srv, th, base = _serve(tmp_path, tmp_path / "no-build") + try: + for path in ("/app/board", "/app/assets/index-abc123.js"): + status, r, body = _get(base, path) + assert status == 503 + assert r.getheader("Content-Type") == "text/html; charset=utf-8" + assert "make web".encode("utf-8") in body + finally: + srv.shutdown() + srv.server_close() + th.join(5) + + +def test_other_paths_are_not_app(app_hub): + status, _, _ = _get(app_hub, "/application") + assert status == 404 # не префикс /app/, обычный 404 API + status, _, _ = _get(app_hub, "/board") + assert status == 401 # API по-прежнему под токеном + + +def test_default_web_dir_is_package_build(tmp_path): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + assert h.web_dir == api.WEB_APP + assert api.WEB_APP.parts[-3:] == ("jira_hub", "web", "app")