feat(api): POST /files и GET /agent/files/{sha256}, files/ 0700 при старте, уборка в tick раз в сутки (#43)

Не base64 — 400 bad_request, пустой — 422 empty_file, больше 10 МБ — 413 too_large; агенту — {file, size, content} в base64, нет файла — 404 no_file; уборка отдельно от автообновления (решения 14–16).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-16 01:41:54 +04:00
parent 19bba60cac
commit e8707d4762
6 changed files with 179 additions and 9 deletions

View File

@ -14,7 +14,7 @@
| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` | | `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` |
| `backup` | копии файла базы, ротация | `paths` | | `backup` | копии файла базы, ротация | `paths` |
| `files` | хранилище файлов вложений `<данные>/files/<sha256>` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` | | `files` | хранилище файлов вложений `<данные>/files/<sha256>` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` |
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` | | `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `files`, `relay`, `release` |
| `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` | | `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` |
| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` | | `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` |
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |

View File

@ -9,12 +9,13 @@
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента. - Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь. - Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
- Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed). - Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed).
- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`, `release/` (опубликованная версия агента); каталог данных, `backups/` и `release/` — права 0700. Порт по умолчанию 8790. - Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790.
- Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное. - Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное.
## Архитектура ## Архитектура
- `src/jira_hub/` — весь код пакета, без разделения на ядро и адаптеры; чистая логика (`commands`, `mirror`, `queue`, `settings`) принимает соединение SQLite параметром. - `src/jira_hub/` — весь код пакета, без разделения на ядро и адаптеры; чистая логика (`commands`, `mirror`, `queue`, `settings`) принимает соединение SQLite параметром.
- `db` (схема, миграции) ← `settings`, `mirror`, `commands` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`, реле общей папки `relay` (поток в `serve`, настройка `relay_dir`)) ← `cli`. - `db` (схема, миграции) ← `settings`, `mirror`, `commands` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`, реле общей папки `relay` (поток в `serve`, настройка `relay_dir`)) ← `cli`.
- `files` (хранилище вложений `files/<sha256>`, каталог — параметр `Hub.files_dir`) ← `queue`, `api`; `mcp_server` берёт из него только предел `MAX_SIZE`.
- `client` (HTTP-клиент хаба) ← `mcp_server` (stdio, инструменты MCP); `importer` → `mirror`/`settings`; `backup` вызывается из `api` и из `db` (копия перед миграцией схемы). - `client` (HTTP-клиент хаба) ← `mcp_server` (stdio, инструменты MCP); `importer` → `mirror`/`settings`; `backup` вызывается из `api` и из `db` (копия перед миграцией схемы).
- Подробная карта модулей и границ — `ARCHITECTURE.md`. - Подробная карта модулей и границ — `ARCHITECTURE.md`.

View File

@ -16,7 +16,7 @@ Python для хаба uv ставит свой (3.13, `.python-version`; сис
Первый `uv sync` или `uv run` после смены сам пересоздаёт `.venv`. Первый `uv sync` или `uv run` после смены сам пересоздаёт `.venv`.
Данные лежат в `~/.local/share/jira-hub/` (каталог переопределяется переменной `JIRA_HUB_HOME`): Данные лежат в `~/.local/share/jira-hub/` (каталог переопределяется переменной `JIRA_HUB_HOME`):
`hub.sqlite` и `backups/`. Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API. `hub.sqlite`, `backups/`, `release/` и `files/` (файлы вложений по sha256; каталоги — 0700). Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API.
## Первый запуск ## Первый запуск
@ -128,7 +128,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
## HTTP API ## HTTP API
Все маршруты, кроме веб-приложения (`GET /app…`) и перенаправления старых адресов (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON, Все маршруты, кроме веб-приложения (`GET /app…`) и перенаправления старых адресов (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON,
ошибки `{error, detail}` (400/401/404/409/422). ошибки `{error, detail}` (400/401/404/409/413/422).
| Маршрут | Токен | Назначение | | Маршрут | Токен | Назначение |
|---|---|---| |---|---|---|
@ -139,6 +139,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
| `POST /agent/state` | агент | загрузка состояния задач пачками | | `POST /agent/state` | агент | загрузка состояния задач пачками |
| `GET /agent/known-keys` | агент | ключи зеркала | | `GET /agent/known-keys` | агент | ключи зеркала |
| `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` | | `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` |
| `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` |
| `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `limit`, `offset` | | `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `limit`, `offset` |
| `GET /issues/{key}` | клиент | задача целиком | | `GET /issues/{key}` | клиент | задача целиком |
| `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон | | `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон |
@ -148,6 +149,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
| `POST /batches/{id}/cancel` | клиент | отмена пакета | | `POST /batches/{id}/cancel` | клиент | отмена пакета |
| `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) | | `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) |
| `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` | | `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` |
| `POST /files` | клиент | загрузить файл вложения `{content: base64}` → `{file: sha256, size}`; не base64 — 400, пустой — 422 `empty_file`, больше 10 МБ (10 485 760 байт) — 413 `too_large`; повтор того же содержимого — тот же `file` |
| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок | | `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок |
| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET) | | `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET) |
@ -223,6 +225,10 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
- Резервные копии: перед первой записывающей командой пакета хаб копирует базу в `backups/<дата-время>-<batch>.sqlite` - Резервные копии: перед первой записывающей командой пакета хаб копирует базу в `backups/<дата-время>-<batch>.sqlite`
и хранит последние `backup_keep` копий (по умолчанию 30). и хранит последние `backup_keep` копий (по умолчанию 30).
- Файлы вложений: `files/<sha256>`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый
раз — через минуту после старта) хаб удаляет файлы старше 30 дней, на которые не ссылается `ensure_attachment` в
очереди или у агента; повтор такой команды после уборки — 422 «файл … не загружен: POST /files», файл загружается
заново.
- Автообновление: если `auto_refresh_minutes > 0` (по умолчанию 60), агент на связи и очередь пуста дольше интервала, - Автообновление: если `auto_refresh_minutes > 0` (по умолчанию 60), агент на связи и очередь пуста дольше интервала,
хаб ставит пакет «Автообновление». хаб ставит пакет «Автообновление».
- Настройки: `PUT /settings` или инструмент `settings_set`; защищены `settings_version`, `schema_version`, `tokens`. - Настройки: `PUT /settings` или инструмент `settings_set`; защищены `settings_version`, `schema_version`, `tokens`.

View File

@ -114,3 +114,11 @@ filename="<имя UTF-8>"; filename*=UTF-8''<percent-encoded>`, `Content-Type: a
13. 15.10.2026 — повторная загрузка: копия того же размера есть — только `os.utime`; другого размера (оборванная 13. 15.10.2026 — повторная загрузка: копия того же размера есть — только `os.utime`; другого размера (оборванная
запись) — файл переписывается; содержимое по sha256 не перепроверяется. Обоснование: имя — sha256 содержимого, запись) — файл переписывается; содержимое по sha256 не перепроверяется. Обоснование: имя — sha256 содержимого,
агент сверяет sha256 при скачивании. Цена: подменённый на диске файл того же размера хаб не заметит (заметит агент). агент сверяет sha256 при скачивании. Цена: подменённый на диске файл того же размера хаб не заметит (заметит агент).
14. 15.10.2026 — `POST /files` отбрасывает пробельные символы (переносы строк) в `content` до строгой проверки base64.
Обоснование: GNU `base64` и многие библиотеки переносят строку через 76 символов; спека говорит только «не base64 —
400». Цена: строка с пробелами внутри тоже принимается.
15. 15.10.2026 — время последней уборки — в памяти процесса (`Hub.last_sweep`); первая уборка — на первом тике после
старта (через 60 с). Обоснование: без новой настройки и файла-метки, уборка дешёвая. Цена: при нескольких
перезапусках хаба в сутки уборок больше одной (спека уточнена: «в пределах работы процесса», рулинг R-1).
16. 15.10.2026 — `GET /agent/files/{id}` с id не из 64 hex нижнего регистра — 404 `no_file`, а не общий `not_found`.
Обоснование: для агента это тот же случай «файла нет». Цена: нет.

View File

@ -1,6 +1,7 @@
"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос). """HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос).
Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md). Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).
Без токена — статика без данных: веб-приложение (GET /app…) и перенаправление старых адресов доски (GET /, GET /links).""" Без токена — статика без данных: веб-приложение (GET /app…) и перенаправление старых адресов доски (GET /, GET /links)."""
import base64
import hmac import hmac
import json import json
import logging import logging
@ -13,7 +14,7 @@ from pathlib import Path
from typing import NamedTuple from typing import NamedTuple
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, unquote, urlparse
from . import backup, db, mirror, queue, relay, release, settings from . import backup, db, files, mirror, queue, relay, release, settings
from .commands import CommandError from .commands import CommandError
from .paths import backups_dir as default_backups_dir from .paths import backups_dir as default_backups_dir
from .paths import secure_dir from .paths import secure_dir
@ -29,6 +30,7 @@ ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # им
NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните " NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните "
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").encode("utf-8") "<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").encode("utf-8")
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений»)
class Raw(NamedTuple): class Raw(NamedTuple):
@ -45,16 +47,19 @@ class ApiError(Exception):
class Hub: class Hub:
def __init__(self, db_path, backups_dir=None, release_dir=None, web_dir=None): def __init__(self, db_path, backups_dir=None, release_dir=None, web_dir=None, files_dir=None):
self.db_path = Path(db_path) self.db_path = Path(db_path)
self.web_dir = Path(web_dir) if web_dir else WEB_APP 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.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release" self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release"
self.files_dir = Path(files_dir) if files_dir else self.db_path.parent / "files"
self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15)
# последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version # последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version
# неизвестен, а не null (hub-hygiene-d, решение 7) # неизвестен, а не null (hub-hygiene-d, решение 7)
self.agent_update = {"update_state": None, "launcher_version": None, "reported": False} self.agent_update = {"update_state": None, "launcher_version": None, "reported": False}
self.claim_wait = 30 self.claim_wait = 30
secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии
secure_dir(self.files_dir) # files/ — 0700 при каждом старте, как backups/
conn = self.conn() conn = self.conn()
settings.ensure_defaults(conn) settings.ensure_defaults(conn)
conn.close() conn.close()
@ -63,17 +68,35 @@ class Hub:
return db.connect(self.db_path) return db.connect(self.db_path)
def tick(self): def tick(self):
"""Один шаг фонового автообновления; любой сбой логируется и не прерывает цикл.""" """Один шаг фоновых работ: автообновление и уборка файлов вложений (не чаще раза в сутки). Сбой каждой
логируется и не мешает ни другой, ни следующему тику."""
conn = None conn = None
try: try:
conn = self.conn() conn = self.conn()
queue.maybe_auto_refresh(conn) queue.maybe_auto_refresh(conn)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
log.exception("автообновление: сбой тика") log.exception("автообновление: сбой тика")
try:
if conn is not None:
self.sweep_files(conn)
except Exception: # noqa: BLE001
log.exception("уборка файлов: сбой")
finally: finally:
if conn: if conn is not None:
conn.close() conn.close()
def sweep_files(self, conn, now=None):
"""Уборка files/ (files.sweep) не чаще раза в SWEEP_EVERY; время прошлой — в памяти процесса, после
перезапуска хаба первая уборка — на первом тике (решение 15). None — ещё рано."""
now = time.time() if now is None else now
if self.last_sweep is not None and now - self.last_sweep < SWEEP_EVERY:
return None
self.last_sweep = now
removed = files.sweep(conn, self.files_dir, now)
if removed:
log.info("уборка файлов: удалено %d", len(removed))
return removed
def note_agent_update(self, body): def note_agent_update(self, body):
"""update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3). """update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3).
Значение неверного типа игнорируется — claim из-за него не отвергается; сам claim отмечается (reported).""" Значение неверного типа игнорируется — claim из-за него не отвергается; сам claim отмечается (reported)."""
@ -141,6 +164,15 @@ def h_agent_release(hub, conn, m, q, body):
return 200, rel return 200, rel
def h_agent_file(hub, conn, m, q, body):
"""Файл вложения агенту: {file, size, content}, content — base64 (проходит и реле папки, и HTTP). id не из 64 hex
нижнего регистра — тот же 404 no_file (решение 16)."""
data = files.read(hub.files_dir, m.group(1))
if data is None:
raise ApiError(404, "no_file", f"файла {m.group(1)} нет в хранилище хаба")
return 200, {"file": m.group(1), "size": len(data), "content": base64.b64encode(data).decode("ascii")}
def h_agent_settings(hub, conn, m, q, body): def h_agent_settings(hub, conn, m, q, body):
return 200, settings.for_agent(conn) return 200, settings.for_agent(conn)
@ -258,6 +290,25 @@ def h_batch_cancel(hub, conn, m, q, body):
raise ApiError(404, "not_found", "batch") raise ApiError(404, "not_found", "batch")
def h_files_post(hub, conn, m, q, body):
"""Загрузка файла вложения: {content: base64} → {file: sha256, size}. Пробельные символы (переносы строк base64)
отбрасываются до строгой проверки (решение 14)."""
content = body.get("content")
if not isinstance(content, str):
raise ApiError(400, "bad_request", "content — строка base64")
try:
data = base64.b64decode("".join(content.split()), validate=True)
except ValueError: # binascii.Error — подкласс ValueError
raise ApiError(400, "bad_request", "content — не base64") from None
try:
sha, size = files.put(hub.files_dir, data)
except files.EmptyFile:
raise ApiError(422, "empty_file", "пустой файл") from None
except files.TooLarge:
raise ApiError(413, "too_large", f"файл больше {files.MAX_SIZE} байт ({len(data)})") from None
return 200, {"file": sha, "size": size}
def h_command_retry(hub, conn, m, q, body): def h_command_retry(hub, conn, m, q, body):
if not isinstance(body.get("author") or "api", str): if not isinstance(body.get("author") or "api", str):
raise ApiError(422, "bad_retry", "author — строка") raise ApiError(422, "bad_retry", "author — строка")
@ -344,6 +395,7 @@ ROUTES = [
("POST", r"^/agent/state$", h_agent_state, "agent"), ("POST", r"^/agent/state$", h_agent_state, "agent"),
("GET", r"^/agent/known-keys$", h_known_keys, "agent"), ("GET", r"^/agent/known-keys$", h_known_keys, "agent"),
("GET", r"^/agent/release$", h_agent_release, "agent"), ("GET", r"^/agent/release$", h_agent_release, "agent"),
("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"),
("GET", r"^/issues$", h_issues, "client"), ("GET", r"^/issues$", h_issues, "client"),
("GET", r"^/issues/([^/]+)$", h_issue, "client"), ("GET", r"^/issues/([^/]+)$", h_issue, "client"),
("GET", r"^/board$", h_board, "client"), ("GET", r"^/board$", h_board, "client"),
@ -352,6 +404,7 @@ ROUTES = [
("GET", r"^/batches$", h_batches_get, "client"), ("GET", r"^/batches$", h_batches_get, "client"),
("GET", r"^/batches/(\d+)$", h_batch_get, "client"), ("GET", r"^/batches/(\d+)$", h_batch_get, "client"),
("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"), ("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"),
("POST", r"^/files$", h_files_post, "client"),
("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"), ("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"),
("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"), ("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"),
("POST", r"^/refresh$", h_refresh, "client"), ("POST", r"^/refresh$", h_refresh, "client"),

View File

@ -1,13 +1,17 @@
import base64
import hashlib
import json import json
import os import os
import stat
import threading import threading
import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from datetime import timedelta from datetime import timedelta
import pytest import pytest
from jira_hub import api, db, queue, release, settings from jira_hub import api, db, files, queue, release, settings
from jira_hub.client import HubClient, HubError from jira_hub.client import HubClient, HubError
@ -606,3 +610,101 @@ def test_bug_without_setting_is_422(hub):
"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}]}) "temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}]})
assert e.value.status == 422 and e.value.detail == "команда 1: нет настройки issue_types.bug" assert e.value.status == 422 and e.value.detail == "команда 1: нет настройки issue_types.bug"
assert hub["client"].get("/batches")["batches"] == [] assert hub["client"].get("/batches")["batches"] == []
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def test_files_upload_and_agent_download(hub, tmp_path):
"""Спека hub-api «Загрузка файла», «Повторная загрузка», «Скачивание файла вложения»."""
c, a = hub["client"], hub["agent"]
sha = hashlib.sha256(b"abc").hexdigest()
out = c.post("/files", {"content": _b64(b"abc")})
assert out == {"file": sha, "size": 3} and sha.startswith("ba7816bf")
assert (tmp_path / "files" / sha).read_bytes() == b"abc"
assert c.post("/files", {"content": _b64(b"abc")}) == out
assert [p.name for p in (tmp_path / "files").iterdir()] == [sha]
got = a.get(f"/agent/files/{sha}")
assert got["file"] == sha and got["size"] == 3
assert hashlib.sha256(base64.b64decode(got["content"])).hexdigest() == sha
with pytest.raises(HubError) as e:
c.get(f"/agent/files/{sha}")
assert e.value.status == 401
with pytest.raises(HubError) as e:
a.post("/files", {"content": _b64(b"abc")})
assert e.value.status == 401
with pytest.raises(HubError) as e:
a.get("/agent/files/" + "0" * 64)
assert (e.value.status, e.value.error) == (404, "no_file")
@pytest.mark.parametrize("body, status, error", [
({}, 400, "bad_request"), ({"content": 5}, 400, "bad_request"), ({"content": None}, 400, "bad_request"),
({"content": "не base64!"}, 400, "bad_request"), ({"content": "YWJj="}, 400, "bad_request"),
({"content": ""}, 422, "empty_file"),
])
def test_files_bad_upload(hub, tmp_path, body, status, error):
with pytest.raises(HubError) as e:
hub["client"].post("/files", body)
assert (e.value.status, e.value.error) == (status, error)
assert list((tmp_path / "files").iterdir()) == []
def test_files_base64_with_line_breaks(hub):
data = bytes(range(256)) * 4
s = _b64(data)
wrapped = "\n".join(s[i:i + 76] for i in range(0, len(s), 76)) + "\n" # как GNU base64
assert hub["client"].post("/files", {"content": wrapped}) == {"file": hashlib.sha256(data).hexdigest(), "size": 1024}
def test_files_too_large_is_413(hub, tmp_path):
"""Спека hub-api «Слишком большой файл»: 11 МБ — 413, тело запроса (≈ 15 МБ base64) до 16 МБ проходит."""
with pytest.raises(HubError) as e:
hub["client"].post("/files", {"content": _b64(b"\0" * (11 * 1024 * 1024))})
assert (e.value.status, e.value.error) == (413, "too_large")
assert list((tmp_path / "files").iterdir()) == []
@pytest.mark.parametrize("bad", ["..", "%2E%2E", hashlib.sha256(b"abc").hexdigest().upper(), "0" * 63, "x" * 64])
def test_agent_file_bad_id_is_no_file(hub, bad):
hub["client"].post("/files", {"content": _b64(b"abc")})
with pytest.raises(HubError) as e:
hub["agent"].get(f"/agent/files/{bad}")
assert (e.value.status, e.value.error) == (404, "no_file")
def test_files_dir_closed_on_every_start(tmp_path):
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
d = tmp_path / "files"
assert h.files_dir == d and stat.S_IMODE(d.stat().st_mode) == 0o700
os.chmod(d, 0o755)
api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
assert stat.S_IMODE(d.stat().st_mode) == 0o700
def test_tick_sweeps_files_once_a_day(hub, tmp_path, monkeypatch):
h = hub["hub"]
sha = hub["client"].post("/files", {"content": _b64(b"old")})["file"]
old = time.time() - 31 * 86400
os.utime(tmp_path / "files" / sha, (old, old))
calls = []
real = files.sweep
monkeypatch.setattr(api.files, "sweep", lambda *a, **k: calls.append(1) or real(*a, **k))
h.tick()
assert len(calls) == 1 and not (tmp_path / "files" / sha).exists()
h.tick()
assert len(calls) == 1 # второй тик в те же сутки — без уборки
h.last_sweep -= api.SWEEP_EVERY
h.tick()
assert len(calls) == 2
def test_tick_sweep_failure_logged_auto_refresh_still_runs(hub, monkeypatch, caplog):
def broken(*args, **kwargs):
raise OSError("диск недоступен")
monkeypatch.setattr(api.files, "sweep", broken)
hub["agent"].post("/agent/claim", {"agent_version": 9}) # агент на связи — тик ставит автообновление
hub["hub"].tick() # не бросает
assert "уборка файлов: сбой" in caplog.text
assert [b["title"] for b in hub["client"].get("/batches")["batches"]] == ["Автообновление"]