feat(api): раздача веб-приложения по /app… без токена, 503 без сборки; тест M-7 (#21)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 20:03:28 +04:00
parent 0f2df4c929
commit bc610a7734
3 changed files with 143 additions and 5 deletions

View File

@ -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 = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните "
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").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")

View File

@ -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"]

100
tests/test_web_app.py Normal file
View File

@ -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("<!doctype html><div id=root></div>", 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"<!doctype html><div id=root></div>"
@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")