feat(api): раздача веб-приложения по /app… без токена, 503 без сборки; тест M-7 (#21)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0f2df4c929
commit
bc610a7734
@ -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")
|
||||
|
||||
@ -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
100
tests/test_web_app.py
Normal 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")
|
||||
Loading…
x
Reference in New Issue
Block a user